Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions charts/nebari-llm-serving/templates/frontend-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,24 @@ metadata:
app.kubernetes.io/component: frontend
data:
# Served by nginx at /config.json. The SPA fetches this at startup so Keycloak
# settings change without rebuilding the image. clientId defaults to the
# nebari-operator SPA-client convention (<namespace>-<nebariapp-name>-spa); the
# NebariApp for the UI is named "<fullname>-key-manager", so the SPA client is
# "<operator-namespace>-<fullname>-key-manager-spa".
# settings and branding change without rebuilding the image. clientId defaults
# to the nebari-operator SPA-client convention (<namespace>-<nebariapp-name>-spa);
# the NebariApp for the UI is named "<fullname>-key-manager", so the SPA client
# is "<operator-namespace>-<fullname>-key-manager-spa". Branding fields are
# rendered as-is (empty strings / empty maps by default) and the SPA falls back
# to the built-in Nebari defaults per field, so an unbranded install is unchanged.
config.json: |
{
"keycloak": {
"url": {{ required "frontend.keycloak.url is required when frontend.enabled=true" .Values.frontend.keycloak.url | quote }},
"realm": {{ .Values.frontend.keycloak.realm | quote }},
"clientId": {{ default (printf "%s-%s-key-manager-spa" (include "nebari-llm-serving.operatorNamespace" .) (include "nebari-llm-serving.fullname" .)) .Values.frontend.keycloak.clientId | quote }}
}{{ with .Values.frontend.title }},
"title": {{ . | quote }}{{ end }}
},
"title": {{ .Values.frontend.title | quote }},
"logoUrl": {{ .Values.frontend.branding.logoUrl | quote }},
"logoUrlDark": {{ .Values.frontend.branding.logoUrlDark | quote }},
"faviconUrl": {{ .Values.frontend.branding.faviconUrl | quote }},
"theme": {{ .Values.frontend.branding.theme | toJson }}
}

# nginx configuration for the frontend pod.
Expand Down
30 changes: 30 additions & 0 deletions charts/nebari-llm-serving/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,36 @@ frontend:
port: 8080
# title - optional browser-tab title override rendered into config.json.
title: ""
# branding - rendered into /config.json at deploy time and applied by the SPA
# before it mounts. Leave everything empty to use the built-in Nebari defaults
# (no visual change from an unbranded install). Outside Kubernetes the same
# fields resolve from a local config.json or BRANDING_* env vars - see the
# Configuration docs and frontend/docker-entrypoint.sh.
branding:
# URL to a custom logo shown in the header. Leave empty for the built-in
# Nebari logo. May be an absolute http(s) URL or a root-relative path.
logoUrl: ""
# URL to a custom dark-mode logo. Leave empty to fall back to logoUrl, then
# the built-in Nebari dark logo.
logoUrlDark: ""
# URL to a custom favicon. Leave empty for the built-in favicon.
faviconUrl: ""
# CSS variable overrides injected at runtime - no image rebuild required.
# Values are validated in the browser and rejected if they contain CSS
# injection characters (';', '{', '}', quotes, url(), javascript:, etc.).
# Supported tokens: primary, primaryForeground, background, foreground,
# secondary, secondaryForeground, muted, mutedForeground, accent,
# accentForeground, border, ring, radius.
# Example:
# theme:
# light:
# primary: "oklch(55% 0.19 250)"
# primaryForeground: "#ffffff"
# dark:
# primary: "oklch(62% 0.21 250)"
theme:
light: {}
dark: {}
# keycloak - connection settings rendered into /config.json at deploy time and
# fetched by the SPA at startup, so the image needs no build-time config.
keycloak:
Expand Down
94 changes: 93 additions & 1 deletion docs/src/content/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@ The React SPA served by nginx. It serves the SPA and a runtime `/config.json`, a
| `frontend.image.pullPolicy` | `Always` | Image pull policy. |
| `frontend.port` | `8080` | Container port nginx listens on. |
| `frontend.service.port` | `8080` | Service port for the frontend. |
| `frontend.title` | (chart default) | Title rendered into `/config.json` and shown in the SPA. |
| `frontend.title` | `""` | Browser-tab title rendered into `/config.json` and shown in the SPA. Empty uses the built-in default. |
| `frontend.branding.logoUrl` | `""` | Header logo (light mode / default). Absolute `http(s)` URL or root-relative path. Empty uses the built-in Nebari logo. |
| `frontend.branding.logoUrlDark` | `""` | Dark-mode header logo. Falls back to `logoUrl`, then the built-in dark logo. |
| `frontend.branding.faviconUrl` | `""` | Favicon URL. Empty uses the built-in favicon. |
| `frontend.branding.theme.light` / `frontend.branding.theme.dark` | `{}` | CSS variable overrides per mode. See [Branding](#branding) for the supported tokens. |
| `frontend.keycloak.url` | `""` | **Required when `frontend.enabled=true`.** External Keycloak base URL the SPA uses for PKCE login (rendered into `/config.json`). |
| `frontend.keycloak.realm` | (chart default) | Keycloak realm the SPA authenticates against. |
| `frontend.keycloak.clientId` | (chart default) | Public PKCE client id the SPA uses. |
Expand All @@ -116,6 +120,94 @@ The React SPA served by nginx. It serves the SPA and a runtime `/config.json`, a

---

## Branding

The key-manager UI ships with built-in Nebari branding (title, logos, favicon,
theme colors) and needs no configuration. Operators can rebrand it **without
rebuilding the image**: branding is delivered at runtime through the same
`/config.json` the SPA already fetches for Keycloak settings, and applied before
React mounts (title, favicon, and theme CSS variables) and in the header (logo).

### Configurable fields

| Field | Description |
|---|---|
| `title` | Browser-tab title. |
| `logoUrl` | Header logo (light mode / default). Absolute `http(s)` URL or root-relative path. |
| `logoUrlDark` | Dark-mode header logo. Falls back to `logoUrl`, then the built-in dark logo. |
| `faviconUrl` | Favicon URL. |
| `theme.light` / `theme.dark` | CSS variable overrides per mode. Supported tokens: `primary`, `primaryForeground`, `background`, `foreground`, `secondary`, `secondaryForeground`, `muted`, `mutedForeground`, `accent`, `accentForeground`, `border`, `ring`, `radius`. |

Every field is optional. Any field left empty uses the built-in Nebari default,
so an unbranded install looks exactly as it does today.

### Kubernetes / Helm

Set `frontend.branding` (and optionally `frontend.title`) in values. The chart
renders them into the `/config.json` ConfigMap mounted into the nginx pod:

```yaml
frontend:
enabled: true
title: "Acme LLM Keys"
branding:
logoUrl: "https://cdn.acme.example/logo.svg"
logoUrlDark: "https://cdn.acme.example/logo-dark.svg"
faviconUrl: "https://cdn.acme.example/favicon.svg"
theme:
light:
primary: "oklch(55% 0.19 250)"
primaryForeground: "#ffffff"
dark:
primary: "oklch(62% 0.21 250)"
```

A branding-only `helm upgrade` rolls the frontend pod automatically (the
deployment is annotated with a checksum of the rendered ConfigMap).

### Outside Kubernetes

Running the standalone `frontend` image (or the Vite dev server) without a chart,
branding resolves from, in order:

1. A **local `config.json`** — the copy baked into the image, or a file mounted
over `/usr/share/nginx/html/config.json`, or one pointed to by
`BRANDING_CONFIG_FILE`.
2. **Environment variables**, overlaid onto that file at container start by the
image entrypoint (requires the standalone image; a no-op under the read-only
Kubernetes mount):

| Env var | Field |
|---|---|
| `BRANDING_TITLE` | `title` |
| `BRANDING_LOGO_URL` | `logoUrl` |
| `BRANDING_LOGO_URL_DARK` | `logoUrlDark` |
| `BRANDING_FAVICON_URL` | `faviconUrl` |
| `BRANDING_THEME` | `theme` (raw JSON, e.g. `'{"light":{"primary":"#0066cc"},"dark":{}}'`) |
| `KEYCLOAK_URL` / `KEYCLOAK_REALM` / `KEYCLOAK_CLIENT_ID` | `keycloak.*` |

```bash
docker run -p 8080:8080 \
-e KEYCLOAK_URL=https://kc.acme.example \
-e BRANDING_TITLE="Acme LLM Keys" \
-e BRANDING_LOGO_URL=https://cdn.acme.example/logo.svg \
ghcr.io/nebari-dev/llm-serving-pack/frontend
```
3. **Built-in Nebari defaults** for any field still unset.

Precedence overall is therefore: chart-rendered `config.json` (in Kubernetes) →
local `config.json` file → `BRANDING_*` env vars → built-in defaults.

### Security

Theme token values are validated in the browser before they are applied: any
value containing CSS-injection characters (`;`, `{`, `}`, `<`, `>`, quotes,
backslash, `url(`, `expression(`, `javascript:`) is dropped rather than injected
into the stylesheet. Logo and favicon URLs are restricted to `http(s)` URLs and
root-relative paths.

---

## LLMModel CRD reference

`LLMModel` is a namespaced custom resource (`llmmodels.llm.nebari.dev`, group `llm.nebari.dev/v1alpha1`). Each instance deploys one model via llm-d and wires it into the pack's routing and access control.
Expand Down
10 changes: 10 additions & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ RUN npm run build
# ─── Stage 2: serve ──────────────────────────────────────────────────────────
FROM nginx:alpine AS final

# jq powers the entrypoint's optional runtime branding overlay (non-k8s only).
RUN apk add --no-cache jq

# Remove the default nginx welcome page and config.
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf

Expand All @@ -28,6 +31,13 @@ COPY --from=builder /app/dist /usr/share/nginx/html
# non-root with a read-only root filesystem.
COPY nginx.default.conf /etc/nginx/nginx.conf

# Entrypoint applies optional BRANDING_* / KEYCLOAK_* env-var overrides to
# config.json at startup (non-Kubernetes rebranding). It is a no-op unless one
# of those vars is set, so the read-only ConfigMap mount used in-cluster is
# untouched.
COPY docker-entrypoint.sh /docker-entrypoint.d/40-branding.sh
RUN chmod +x /docker-entrypoint.d/40-branding.sh

EXPOSE 8080

CMD ["nginx", "-g", "daemon off;"]
103 changes: 103 additions & 0 deletions frontend/docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/bin/sh
# Runtime branding for the standalone (non-Kubernetes) frontend image.
#
# Installed into /docker-entrypoint.d/ so the stock nginx image runs it before
# starting nginx. In Kubernetes the Helm chart renders /config.json into a
# ConfigMap and mounts it read-only over the file baked into the image, so
# branding is fully handled there and this script leaves that file alone (it
# only writes when a BRANDING_* / KEYCLOAK_* / BRANDING_CONFIG_FILE env var is
# set, which the chart never sets). Outside Kubernetes it lets an operator
# rebrand without rebuilding:
#
# Precedence (highest first), resolved per field:
# 1. A mounted /config.json (or BRANDING_CONFIG_FILE) — a full local file
# 2. Individual BRANDING_* / KEYCLOAK_* env vars overlaid on the base file
# 3. The placeholder config.json baked into the image
# 4. Built-in Nebari defaults (applied by the SPA when a field is empty)
#
# Supported env vars:
# BRANDING_CONFIG_FILE path to a JSON file used as the base config.json
# BRANDING_TITLE browser-tab title
# BRANDING_LOGO_URL header logo URL (light / default)
# BRANDING_LOGO_URL_DARK dark-mode header logo URL
# BRANDING_FAVICON_URL favicon URL
# BRANDING_THEME raw JSON theme object, e.g.
# '{"light":{"primary":"#0066cc"},"dark":{}}'
# KEYCLOAK_URL / KEYCLOAK_REALM / KEYCLOAK_CLIENT_ID Keycloak overrides
#
# The script never aborts container startup: any failure to build or write the
# config is logged and the existing file is kept. It always exits 0.
set -u

CONFIG_PATH="${CONFIG_PATH:-/usr/share/nginx/html/config.json}"

# Do nothing unless the operator asked for a runtime override. This keeps the
# read-only Kubernetes mount (and read-only root filesystem) untouched.
if [ -z "${BRANDING_CONFIG_FILE:-}" ] \
&& [ -z "${BRANDING_TITLE:-}" ] \
&& [ -z "${BRANDING_LOGO_URL:-}" ] \
&& [ -z "${BRANDING_LOGO_URL_DARK:-}" ] \
&& [ -z "${BRANDING_FAVICON_URL:-}" ] \
&& [ -z "${BRANDING_THEME:-}" ] \
&& [ -z "${KEYCLOAK_URL:-}" ] \
&& [ -z "${KEYCLOAK_REALM:-}" ] \
&& [ -z "${KEYCLOAK_CLIENT_ID:-}" ]; then
exit 0
fi

base="$CONFIG_PATH"
if [ -n "${BRANDING_CONFIG_FILE:-}" ]; then
if [ -r "$BRANDING_CONFIG_FILE" ]; then
base="$BRANDING_CONFIG_FILE"
else
echo "branding: BRANDING_CONFIG_FILE=$BRANDING_CONFIG_FILE not readable; ignoring" >&2
fi
fi

if [ ! -r "$base" ]; then
echo "branding: no readable base config at $base; skipping" >&2
exit 0
fi

# Build a jq program that sets only the fields whose env var is provided.
# Scalars come in as strings via --arg; the theme object via --argjson.
filter='.'
set -- # positional args accumulate the jq --arg / --argjson pairs
if [ -n "${KEYCLOAK_URL:-}" ]; then
filter="$filter | .keycloak.url = \$kcUrl"; set -- "$@" --arg kcUrl "$KEYCLOAK_URL"
fi
if [ -n "${KEYCLOAK_REALM:-}" ]; then
filter="$filter | .keycloak.realm = \$kcRealm"; set -- "$@" --arg kcRealm "$KEYCLOAK_REALM"
fi
if [ -n "${KEYCLOAK_CLIENT_ID:-}" ]; then
filter="$filter | .keycloak.clientId = \$kcClient"; set -- "$@" --arg kcClient "$KEYCLOAK_CLIENT_ID"
fi
if [ -n "${BRANDING_TITLE:-}" ]; then
filter="$filter | .title = \$title"; set -- "$@" --arg title "$BRANDING_TITLE"
fi
if [ -n "${BRANDING_LOGO_URL:-}" ]; then
filter="$filter | .logoUrl = \$logo"; set -- "$@" --arg logo "$BRANDING_LOGO_URL"
fi
if [ -n "${BRANDING_LOGO_URL_DARK:-}" ]; then
filter="$filter | .logoUrlDark = \$logoDark"; set -- "$@" --arg logoDark "$BRANDING_LOGO_URL_DARK"
fi
if [ -n "${BRANDING_FAVICON_URL:-}" ]; then
filter="$filter | .faviconUrl = \$favicon"; set -- "$@" --arg favicon "$BRANDING_FAVICON_URL"
fi
if [ -n "${BRANDING_THEME:-}" ]; then
filter="$filter | .theme = \$theme"; set -- "$@" --argjson theme "$BRANDING_THEME"
fi

tmp="$(mktemp)"
if jq "$@" "$filter" "$base" > "$tmp" 2>/dev/null; then
if cat "$tmp" > "$CONFIG_PATH" 2>/dev/null; then
echo "branding: applied runtime branding to $CONFIG_PATH" >&2
else
echo "branding: could not write $CONFIG_PATH (read-only?); keeping existing config" >&2
fi
else
echo "branding: failed to build config (invalid BRANDING_THEME JSON?); keeping existing config" >&2
fi
rm -f "$tmp"

exit 0
7 changes: 6 additions & 1 deletion frontend/public/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,10 @@
"url": "http://localhost:8180",
"realm": "nebari",
"clientId": "nebari-frontend-spa"
}
},
"title": "",
"logoUrl": "",
"logoUrlDark": "",
"faviconUrl": "",
"theme": { "light": {}, "dark": {} }
}
Loading
Loading