Skip to content

Commit 07592f9

Browse files
authored
Merge pull request #111 from nebari-dev/react-ui
React UI
2 parents 672e2ce + 03b27ca commit 07592f9

111 files changed

Lines changed: 15193 additions & 1772 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/build-images.yaml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,39 @@ jobs:
118118
push: true
119119
tags: ${{ steps.meta.outputs.tags }}
120120
labels: ${{ steps.meta.outputs.labels }}
121+
122+
build-frontend:
123+
name: Build and push frontend image
124+
runs-on: ubuntu-latest
125+
permissions:
126+
contents: read
127+
packages: write
128+
steps:
129+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
130+
131+
- name: Log in to GHCR
132+
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
133+
with:
134+
registry: ${{ env.REGISTRY }}
135+
username: ${{ github.actor }}
136+
password: ${{ secrets.GITHUB_TOKEN }}
137+
138+
- name: Extract metadata
139+
id: meta
140+
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
141+
with:
142+
images: ${{ env.IMAGE_PREFIX }}/frontend
143+
tags: |
144+
type=sha
145+
type=ref,event=branch
146+
type=semver,pattern=v{{version}}
147+
type=raw,value=latest,enable={{is_default_branch}}
148+
149+
- name: Build and push frontend image
150+
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
151+
with:
152+
context: frontend/
153+
file: frontend/Dockerfile
154+
push: true
155+
tags: ${{ steps.meta.outputs.tags }}
156+
labels: ${{ steps.meta.outputs.labels }}

.github/workflows/lint.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,26 @@ jobs:
4141
version: v2.4.0
4242
working-directory: key-manager
4343

44+
lint-frontend:
45+
name: Lint frontend
46+
runs-on: ubuntu-latest
47+
steps:
48+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
49+
50+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
51+
with:
52+
node-version-file: frontend/.node-version
53+
cache: npm
54+
cache-dependency-path: frontend/package-lock.json
55+
56+
- name: Install deps
57+
working-directory: frontend
58+
run: npm ci
59+
60+
- name: Biome check
61+
working-directory: frontend
62+
run: npm run check
63+
4464
lint-helm:
4565
name: Lint Helm chart
4666
runs-on: ubuntu-latest

.github/workflows/test.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,23 @@ jobs:
3636
- name: Run tests
3737
working-directory: key-manager
3838
run: go test ./...
39+
40+
test-frontend:
41+
name: Test frontend
42+
runs-on: ubuntu-latest
43+
steps:
44+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
45+
46+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
47+
with:
48+
node-version-file: frontend/.node-version
49+
cache: npm
50+
cache-dependency-path: frontend/package-lock.json
51+
52+
- name: Install deps
53+
working-directory: frontend
54+
run: npm ci
55+
56+
- name: Run tests
57+
working-directory: frontend
58+
run: npm test

README.md

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ You apply an `LLMModel` custom resource and the operator handles the rest: model
88

99
Each model gets per-model access control via OIDC groups (works with any OIDC provider, tested against Keycloak). Two auth endpoints are created per model: external access via API keys, and internal (in-cluster) access via JWT. Both paths go through Envoy AI Gateway for token counting and rate limiting.
1010

11-
An optional key manager web UI lets users generate and revoke API keys for models they have access to.
11+
An optional key manager lets users generate and revoke API keys for models they have access to: a React single-page app (served by its own nginx image) backed by an API-only Go service, with SPA-managed Keycloak login.
1212

1313
Models can be loaded from HuggingFace (default) or mounted as OCI/modelcar images. Model downloads use a purpose-built [distroless container image](model-downloader/) with pixi-managed dependencies for reproducibility.
1414

@@ -205,7 +205,9 @@ response = client.chat.completions.create(
205205
| `defaults.epp.image` | Endpoint Picker (llm-d-inference-scheduler) image | `ghcr.io/llm-d/llm-d-inference-scheduler:v0.8.0` |
206206
| `defaults.storage.storageClassName` | Default StorageClass for model PVCs (empty = cluster default) | `""` |
207207
| `defaults.monitoring.enabled` | Enable PodMonitor for Prometheus scraping | `true` |
208-
| `keyManager.enabled` | Deploy the key manager web UI | `true` |
208+
| `keyManager.enabled` | Deploy the key manager API service | `true` |
209+
| `frontend.enabled` | Deploy the React UI (nginx) frontend | `true` |
210+
| `frontend.keycloak.url` | External Keycloak URL for SPA login (required when `frontend.enabled=true`) | `""` |
209211

210212
## Architecture
211213

@@ -223,7 +225,9 @@ Admin applies LLMModel CR
223225
|
224226
Key Manager (optional)
225227
|
226-
+---> Web UI behind NebariApp (Keycloak/OIDC login)
228+
+---> React SPA (nginx image) behind NebariApp; SPA-managed Keycloak PKCE login
229+
+---> nginx serves the SPA + /config.json and proxies /api to the key-manager
230+
+---> key-manager is API-only; validates the Keycloak bearer against JWKS in-process
227231
+---> Generates API keys, writes to K8s Secrets
228232
+---> Envoy Gateway validates keys natively
229233
```
@@ -233,7 +237,8 @@ Admin applies LLMModel CR
233237
| Image | Description |
234238
|-------|-------------|
235239
| `ghcr.io/nebari-dev/nebari-llm-serving-pack/operator` | LLM operator - reconciles LLMModel CRDs |
236-
| `ghcr.io/nebari-dev/nebari-llm-serving-pack/key-manager` | Key manager web UI and API |
240+
| `ghcr.io/nebari-dev/nebari-llm-serving-pack/key-manager` | Key manager REST API |
241+
| `ghcr.io/nebari-dev/nebari-llm-serving-pack/frontend` | LLM serving pack React UI (nginx) |
237242
| `ghcr.io/nebari-dev/nebari-llm-serving-pack/model-downloader` | Model download init container (distroless, pixi-managed) |
238243
239244
### Infrastructure requirements
@@ -263,8 +268,8 @@ make deploy
263268
# Apply a test model
264269
make apply-test-model
265270
266-
# Watch reconciliation
267-
kubectl -n llm-serving get llmmodels -w
271+
# Watch reconciliation (models live in the operator's namespace)
272+
kubectl -n llm-operator-system get llmmodels -w
268273
269274
# Tail logs
270275
make logs-operator
@@ -274,11 +279,25 @@ make logs-key-manager
274279
make teardown
275280
```
276281

282+
### Key manager UI
283+
284+
The key manager web UI is a [React](https://react.dev) + TypeScript app (Vite, Tailwind, shadcn/ui) in [`frontend/`](frontend/). In production it ships as its own nginx image (`ghcr.io/nebari-dev/nebari-llm-serving-pack/frontend`) that serves the SPA and proxies `/api` to the API-only key-manager; it is not embedded in the Go binary. For a one-command dev loop that needs **no Keycloak**:
285+
286+
```bash
287+
# One-time: copy dev/.env.example to dev/.env and set OPENROUTER_API_KEY
288+
cd dev && make run-dev
289+
```
290+
291+
This idempotently brings up the kind cluster, deploys the operator and a **dev-mode** key manager (auth bypassed, a fixed `dev` identity injected - see `LLM_DEV_MODE`), applies a few OpenRouter passthrough models so the list is populated, port-forwards the key-manager API to `localhost:8080`, and starts the Vite dev server at **http://localhost:5173** with hot reload. Edit files under `frontend/src/` and the browser updates live - there is no build step and no login. Press `Ctrl-C` to stop (the cluster is left running for the next run).
292+
293+
Requires [Node.js](https://nodejs.org) + npm and an [OpenRouter API key](https://openrouter.ai/keys). To exercise the real Keycloak login flow instead of the bypass, deploy Keycloak (`make deploy-keycloak && make pf-keycloak`) and run the UI without the dev flag (`cd frontend && npm run dev`).
294+
277295
Run tests directly:
278296

279297
```bash
280298
cd operator && make test
281299
cd key-manager && go test ./...
300+
cd frontend && npm test
282301
```
283302

284303
### Documentation site
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
{{- if and .Values.frontend.enabled .Values.keyManager.enabled }}
2+
apiVersion: v1
3+
kind: ConfigMap
4+
metadata:
5+
name: {{ include "nebari-llm-serving.fullname" . }}-frontend-config
6+
namespace: {{ include "nebari-llm-serving.operatorNamespace" . }}
7+
labels:
8+
{{- include "nebari-llm-serving.labels" . | nindent 4 }}
9+
app.kubernetes.io/component: frontend
10+
data:
11+
# Served by nginx at /config.json. The SPA fetches this at startup so Keycloak
12+
# settings change without rebuilding the image. clientId defaults to the
13+
# nebari-operator SPA-client convention (<namespace>-<nebariapp-name>-spa); the
14+
# NebariApp for the UI is named "<fullname>-key-manager", so the SPA client is
15+
# "<operator-namespace>-<fullname>-key-manager-spa".
16+
config.json: |
17+
{
18+
"keycloak": {
19+
"url": {{ required "frontend.keycloak.url is required when frontend.enabled=true" .Values.frontend.keycloak.url | quote }},
20+
"realm": {{ .Values.frontend.keycloak.realm | quote }},
21+
"clientId": {{ default (printf "%s-%s-key-manager-spa" (include "nebari-llm-serving.operatorNamespace" .) (include "nebari-llm-serving.fullname" .)) .Values.frontend.keycloak.clientId | quote }}
22+
}{{ with .Values.frontend.title }},
23+
"title": {{ . | quote }}{{ end }}
24+
}
25+
26+
# nginx configuration for the frontend pod.
27+
#
28+
# Traffic flow (production):
29+
# Browser → Gateway → frontend Service (nginx:{{ .Values.frontend.port }})
30+
# ├─ /api/* → proxy_pass to the key-manager ClusterIP (cluster DNS, never
31+
# │ public); nginx forwards the Authorization: Bearer header as-is
32+
# │ and the key-manager validates it against Keycloak JWKS.
33+
# └─ /* → serve the SPA; keycloak-js handles OIDC PKCE in the browser.
34+
nginx.conf: |
35+
worker_processes auto;
36+
pid /tmp/nginx.pid;
37+
38+
events {
39+
worker_connections 1024;
40+
}
41+
42+
http {
43+
include /etc/nginx/mime.types;
44+
default_type application/octet-stream;
45+
46+
client_body_temp_path /tmp/client_temp;
47+
proxy_temp_path /tmp/proxy_temp_path;
48+
fastcgi_temp_path /tmp/fastcgi_temp;
49+
uwsgi_temp_path /tmp/uwsgi_temp;
50+
scgi_temp_path /tmp/scgi_temp;
51+
52+
sendfile on;
53+
keepalive_timeout 65;
54+
55+
access_log /dev/stdout;
56+
error_log /dev/stderr warn;
57+
58+
server {
59+
listen {{ .Values.frontend.port }};
60+
server_name _;
61+
62+
root /usr/share/nginx/html;
63+
index index.html;
64+
65+
# Security headers. add_header does NOT inherit into a location that
66+
# sets its own add_header, so these cover the SPA document (location
67+
# /); the config.json block below repeats the ones that matter for it.
68+
# The framing headers matter because the UI exposes one-click "revoke
69+
# key" - deny embedding to block clickjacking.
70+
add_header X-Frame-Options "DENY" always;
71+
add_header Content-Security-Policy "frame-ancestors 'none'" always;
72+
add_header X-Content-Type-Options "nosniff" always;
73+
74+
gzip on;
75+
gzip_types text/plain text/css application/json application/javascript
76+
text/xml application/xml application/xml+rss text/javascript
77+
image/svg+xml;
78+
gzip_min_length 1024;
79+
80+
# Proxy /api/* to the key-manager ClusterIP - never exposed publicly.
81+
location /api/ {
82+
proxy_pass http://{{ include "nebari-llm-serving.fullname" . }}-key-manager.{{ include "nebari-llm-serving.operatorNamespace" . }}.svc.cluster.local:8080/api/;
83+
proxy_set_header Authorization $http_authorization;
84+
proxy_set_header X-Real-IP $remote_addr;
85+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
86+
proxy_set_header Host $host;
87+
proxy_http_version 1.1;
88+
}
89+
90+
# Cache content-hashed static assets aggressively.
91+
location ~* \.(js|css|png|svg|ico|woff2?|ttf|eot)$ {
92+
expires 1y;
93+
add_header Cache-Control "public, immutable";
94+
try_files $uri =404;
95+
}
96+
97+
location /healthz {
98+
access_log off;
99+
return 200 "ok\n";
100+
add_header Content-Type text/plain;
101+
}
102+
103+
# Runtime config: never cache, so Keycloak settings changes take
104+
# effect on the next page load instead of being pinned by a stale copy.
105+
location = /config.json {
106+
add_header Cache-Control "no-store" always;
107+
add_header X-Content-Type-Options "nosniff" always;
108+
try_files $uri =404;
109+
}
110+
111+
# SPA fallback.
112+
location / {
113+
try_files $uri $uri/ /index.html;
114+
}
115+
}
116+
}
117+
{{- end }}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
{{- if and .Values.frontend.enabled .Values.keyManager.enabled }}
2+
apiVersion: apps/v1
3+
kind: Deployment
4+
metadata:
5+
name: {{ include "nebari-llm-serving.fullname" . }}-frontend
6+
namespace: {{ include "nebari-llm-serving.operatorNamespace" . }}
7+
labels:
8+
{{- include "nebari-llm-serving.labels" . | nindent 4 }}
9+
app.kubernetes.io/component: frontend
10+
spec:
11+
replicas: 1
12+
selector:
13+
matchLabels:
14+
app: {{ include "nebari-llm-serving.fullname" . }}-frontend
15+
template:
16+
metadata:
17+
annotations:
18+
# Roll the pod when the rendered nginx.conf / config.json changes: a
19+
# ConfigMap update alone does not restart its consumers, so without this a
20+
# config-only `helm upgrade` would leave the old config live in the pod.
21+
checksum/config: {{ include (print $.Template.BasePath "/frontend-configmap.yaml") . | sha256sum }}
22+
labels:
23+
app: {{ include "nebari-llm-serving.fullname" . }}-frontend
24+
{{- include "nebari-llm-serving.selectorLabels" . | nindent 8 }}
25+
app.kubernetes.io/component: frontend
26+
spec:
27+
securityContext:
28+
runAsNonRoot: true
29+
runAsUser: 101 # nginx default non-root uid
30+
seccompProfile:
31+
type: RuntimeDefault
32+
containers:
33+
# nginx serves the React SPA and proxies /api/* to the key-manager
34+
# ClusterIP. This is the service the NebariApp targets; auth is handled
35+
# client-side by keycloak-js (PKCE/S256) and validated by the key-manager.
36+
- name: frontend
37+
image: {{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}
38+
imagePullPolicy: {{ .Values.frontend.image.pullPolicy | default "IfNotPresent" }}
39+
ports:
40+
- name: http
41+
containerPort: {{ .Values.frontend.port }}
42+
protocol: TCP
43+
securityContext:
44+
allowPrivilegeEscalation: false
45+
readOnlyRootFilesystem: true
46+
capabilities:
47+
drop: [ALL]
48+
# nginx serves /healthz (a static 200) for both probes.
49+
livenessProbe:
50+
httpGet:
51+
path: /healthz
52+
port: http
53+
initialDelaySeconds: 5
54+
periodSeconds: 15
55+
timeoutSeconds: 3
56+
failureThreshold: 3
57+
readinessProbe:
58+
httpGet:
59+
path: /healthz
60+
port: http
61+
initialDelaySeconds: 3
62+
periodSeconds: 10
63+
timeoutSeconds: 3
64+
failureThreshold: 3
65+
resources:
66+
limits:
67+
cpu: 200m
68+
memory: 128Mi
69+
requests:
70+
cpu: 25m
71+
memory: 32Mi
72+
volumeMounts:
73+
# nginx needs writable /tmp, /var/cache/nginx, and /var/run.
74+
- name: nginx-tmp
75+
mountPath: /tmp
76+
- name: nginx-cache
77+
mountPath: /var/cache/nginx
78+
- name: nginx-run
79+
mountPath: /var/run
80+
# Helm-rendered nginx.conf (adds the /api/ proxy to the key-manager).
81+
- name: frontend-config
82+
mountPath: /etc/nginx/nginx.conf
83+
subPath: nginx.conf
84+
readOnly: true
85+
# Helm-rendered config.json, overriding the placeholder baked into
86+
# the image at build time.
87+
- name: frontend-config
88+
mountPath: /usr/share/nginx/html/config.json
89+
subPath: config.json
90+
readOnly: true
91+
volumes:
92+
- name: nginx-tmp
93+
emptyDir: {}
94+
- name: nginx-cache
95+
emptyDir: {}
96+
- name: nginx-run
97+
emptyDir: {}
98+
- name: frontend-config
99+
configMap:
100+
name: {{ include "nebari-llm-serving.fullname" . }}-frontend-config
101+
terminationGracePeriodSeconds: 10
102+
{{- end }}

0 commit comments

Comments
 (0)