Skip to content

Commit 7367fb5

Browse files
committed
feat(dev): add make run-dev one-command UI dev environment with hot reload
Frontend devs working on the key-manager UI now need only an OpenRouter key in dev/.env and `make run-dev`. The target idempotently brings up the kind cluster, operator, dev-mode key-manager, and three OpenRouter passthrough models, then port-forwards the key-manager and starts a hot-reloading UI dev server. - dev/uidev: a zero-dependency (stdlib-only) Go dev server that serves the UI static files from disk, proxies /api/* to the port-forwarded key-manager, and live-reloads the browser on file edits. The UI is plain static files, so no build step or npm is involved. - dev/run-dev.sh + `make run-dev`: orchestrates cluster/deploy/models/port-forward /UI server, loading OPENROUTER_API_KEY from a gitignored dev/.env. - dev/manifests/dev-models.yaml: three passthrough models so the UI list is populated. - docs/ui-development.md: frontend-dev guide (setup, editing, dev-mode auth, shipping changes, API table, troubleshooting), linked from getting-started. Refs #114
1 parent 6f4ffe3 commit 7367fb5

9 files changed

Lines changed: 433 additions & 1 deletion

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
CLAUDE.md
22
docs/superpowers/
33
.claude/
4+
5+
# Local dev secrets (OPENROUTER_API_KEY, etc.)
6+
.env

dev/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Copy to dev/.env and fill in. dev/.env is gitignored.
2+
#
3+
# OpenRouter API key (https://openrouter.ai/keys). Used as the upstream
4+
# provider credential for the PassthroughModels that `make run-dev` deploys.
5+
OPENROUTER_API_KEY=

dev/Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ GIE_VERSION ?= v1.4.0
1010
ENVOY_GATEWAY_VERSION ?= v1.6.7
1111
AI_GATEWAY_VERSION ?= v0.5.0
1212

13-
.PHONY: setup teardown build-images load-images deploy deploy-operator deploy-key-manager apply-test-model apply-passthrough-model create-openrouter-secret ui logs-operator logs-key-manager clean help
13+
.PHONY: setup teardown build-images load-images deploy deploy-operator deploy-key-manager apply-test-model apply-passthrough-model create-openrouter-secret run-dev ui logs-operator logs-key-manager clean help
1414

1515
help: ## Show this help
1616
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n\nTargets:\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
1717

18+
run-dev: ## One-command UI dev environment: cluster + models + port-forward + hot-reload UI server (needs dev/.env with OPENROUTER_API_KEY)
19+
./run-dev.sh
20+
1821
setup: ## Create kind cluster and install dependencies
1922
kind create cluster --name $(CLUSTER_NAME)
2023
# cert-manager

dev/manifests/dev-models.yaml

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Three OpenRouter passthrough models for the local dev environment, applied by
2+
# `make run-dev` so the key-manager UI has a populated model list to work with.
3+
# Each is its own PassthroughModel so the UI lists three distinct models.
4+
#
5+
# Note: API keys are currently gateway-scoped, not model-scoped (see
6+
# nebari-dev/llm-serving-pack#116), so a key minted for one of these works for
7+
# all of them. That does not affect UI development.
8+
apiVersion: llm.nebari.dev/v1alpha1
9+
kind: PassthroughModel
10+
metadata:
11+
name: claude-sonnet-45
12+
namespace: llm-operator-system
13+
spec:
14+
provider:
15+
hostname: openrouter.ai
16+
schemaVersion: api/v1
17+
credentialSecretName: openrouter-api-key
18+
models:
19+
catchAll: false
20+
declared:
21+
- anthropic/claude-sonnet-4.5
22+
access:
23+
groups:
24+
- llm
25+
---
26+
apiVersion: llm.nebari.dev/v1alpha1
27+
kind: PassthroughModel
28+
metadata:
29+
name: gemini-25-flash
30+
namespace: llm-operator-system
31+
spec:
32+
provider:
33+
hostname: openrouter.ai
34+
schemaVersion: api/v1
35+
credentialSecretName: openrouter-api-key
36+
models:
37+
catchAll: false
38+
declared:
39+
- google/gemini-2.5-flash
40+
access:
41+
groups:
42+
- llm
43+
---
44+
apiVersion: llm.nebari.dev/v1alpha1
45+
kind: PassthroughModel
46+
metadata:
47+
name: llama-33-70b
48+
namespace: llm-operator-system
49+
spec:
50+
provider:
51+
hostname: openrouter.ai
52+
schemaVersion: api/v1
53+
credentialSecretName: openrouter-api-key
54+
models:
55+
catchAll: false
56+
declared:
57+
- meta-llama/llama-3.3-70b-instruct
58+
access:
59+
groups:
60+
- llm

dev/run-dev.sh

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#!/usr/bin/env bash
2+
# One-command local dev environment for the key-manager UI.
3+
#
4+
# Brings up (idempotently): a kind cluster with all dependencies, the operator,
5+
# the dev-mode key-manager, and three OpenRouter passthrough models. Then it
6+
# port-forwards the key-manager and starts a hot-reloading UI dev server.
7+
#
8+
# Only prerequisite: dev/.env with OPENROUTER_API_KEY set (see .env.example).
9+
set -euo pipefail
10+
11+
cd "$(dirname "$0")"
12+
13+
CLUSTER_NAME="${CLUSTER_NAME:-llm-serving-test}"
14+
NS=llm-operator-system
15+
KM_PORT="${KM_PORT:-8080}"
16+
UI_PORT="${UI_PORT:-5173}"
17+
18+
# --- load .env -------------------------------------------------------------
19+
if [[ -f .env ]]; then
20+
set -a; . ./.env; set +a
21+
fi
22+
if [[ -z "${OPENROUTER_API_KEY:-}" ]]; then
23+
echo "ERROR: OPENROUTER_API_KEY is not set. Copy dev/.env.example to dev/.env and fill it in." >&2
24+
exit 1
25+
fi
26+
27+
# --- 1. cluster + dependencies --------------------------------------------
28+
if ! kind get clusters 2>/dev/null | grep -qx "$CLUSTER_NAME"; then
29+
echo "==> kind cluster '$CLUSTER_NAME' not found; running full setup (a few minutes)..."
30+
make setup
31+
fi
32+
33+
# --- 2. operator + key-manager --------------------------------------------
34+
if ! kubectl -n "$NS" get deploy/llm-key-manager >/dev/null 2>&1; then
35+
echo "==> building images and deploying operator + key-manager..."
36+
make build-images
37+
make load-images
38+
make deploy
39+
else
40+
echo "==> operator + key-manager already deployed."
41+
fi
42+
43+
# --- 3. provider credential + models --------------------------------------
44+
echo "==> applying OpenRouter credential and dev models..."
45+
kubectl -n "$NS" create secret generic openrouter-api-key \
46+
--from-literal=apiKey="$OPENROUTER_API_KEY" \
47+
--dry-run=client -o yaml | kubectl apply -f - >/dev/null
48+
kubectl apply -f manifests/dev-models.yaml >/dev/null
49+
for m in claude-sonnet-45 gemini-25-flash llama-33-70b; do
50+
kubectl -n "$NS" wait passthroughmodel/$m --for=jsonpath='{.status.phase}'=Ready --timeout=90s
51+
done
52+
53+
# --- 4. port-forward + UI dev server --------------------------------------
54+
cleanup() {
55+
echo
56+
echo "==> shutting down..."
57+
[[ -n "${PF_PID:-}" ]] && kill "$PF_PID" 2>/dev/null || true
58+
}
59+
trap cleanup EXIT INT TERM
60+
61+
echo "==> port-forwarding key-manager to localhost:${KM_PORT}..."
62+
kubectl -n "$NS" port-forward svc/llm-key-manager "${KM_PORT}:8080" >/tmp/km-portforward.log 2>&1 &
63+
PF_PID=$!
64+
for _ in $(seq 1 20); do
65+
if grep -q "Forwarding from" /tmp/km-portforward.log 2>/dev/null; then break; fi
66+
sleep 0.5
67+
done
68+
69+
echo
70+
echo " key-manager (embedded UI + API): http://localhost:${KM_PORT}"
71+
echo " hot-reload UI dev server: http://localhost:${UI_PORT} <-- develop here"
72+
echo " edit key-manager/internal/ui/static/* and the browser reloads automatically."
73+
echo " Ctrl-C to stop."
74+
echo
75+
76+
# Foreground: exits on Ctrl-C, which triggers cleanup of the port-forward.
77+
( cd uidev && go run . \
78+
-static ../../key-manager/internal/ui/static \
79+
-api "http://localhost:${KM_PORT}" \
80+
-addr ":${UI_PORT}" )

dev/uidev/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module uidev
2+
3+
go 1.25

dev/uidev/main.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Command uidev is a zero-dependency dev server for the key-manager UI.
2+
//
3+
// It serves the UI's static files from disk (so edits show up immediately,
4+
// unlike the embedded copy baked into the key-manager image), proxies /api/*
5+
// to the running key-manager (normally a kubectl port-forward on :8080), and
6+
// live-reloads the browser when any static file changes. Frontend devs edit
7+
// key-manager/internal/ui/static/* and just refresh-free reload.
8+
//
9+
// Run via `make run-dev`, or directly:
10+
//
11+
// go run . -static ../../key-manager/internal/ui/static -api http://localhost:8080 -addr :5173
12+
package main
13+
14+
import (
15+
"flag"
16+
"fmt"
17+
"io/fs"
18+
"log"
19+
"net/http"
20+
"net/http/httputil"
21+
"net/url"
22+
"os"
23+
"path/filepath"
24+
"strings"
25+
"time"
26+
)
27+
28+
func main() {
29+
staticDir := flag.String("static", "../../key-manager/internal/ui/static", "path to the UI static files")
30+
apiURL := flag.String("api", "http://localhost:8080", "key-manager API base URL to proxy /api/* to")
31+
addr := flag.String("addr", ":5173", "address to listen on")
32+
flag.Parse()
33+
34+
absStatic, err := filepath.Abs(*staticDir)
35+
if err != nil || !dirExists(absStatic) {
36+
log.Fatalf("static dir not found: %s", *staticDir)
37+
}
38+
api, err := url.Parse(*apiURL)
39+
if err != nil {
40+
log.Fatalf("invalid -api URL %q: %v", *apiURL, err)
41+
}
42+
43+
// Proxy /api/* to the key-manager (the dev-mode instance bypasses auth).
44+
proxy := httputil.NewSingleHostReverseProxy(api)
45+
46+
mux := http.NewServeMux()
47+
mux.Handle("/api/", proxy)
48+
mux.HandleFunc("/__livereload", liveReloadHandler(absStatic))
49+
mux.HandleFunc("/", staticHandler(absStatic))
50+
51+
fmt.Printf("UI dev server: http://localhost%s (hot reload)\n", normalizeAddr(*addr))
52+
fmt.Printf(" serving: %s\n", absStatic)
53+
fmt.Printf(" /api/* proxied: %s\n", api)
54+
if err := http.ListenAndServe(*addr, mux); err != nil {
55+
log.Fatal(err)
56+
}
57+
}
58+
59+
// staticHandler serves files from dir. For HTML responses it injects a small
60+
// live-reload script before </body> so edits trigger a browser reload.
61+
func staticHandler(dir string) http.HandlerFunc {
62+
return func(w http.ResponseWriter, r *http.Request) {
63+
clean := filepath.Clean(r.URL.Path)
64+
if clean == "/" || clean == "." {
65+
clean = "/index.html"
66+
}
67+
full := filepath.Join(dir, clean)
68+
// Keep the response inside the static dir.
69+
if !strings.HasPrefix(full, dir) {
70+
http.NotFound(w, r)
71+
return
72+
}
73+
if strings.HasSuffix(clean, ".html") {
74+
b, err := os.ReadFile(full)
75+
if err != nil {
76+
http.NotFound(w, r)
77+
return
78+
}
79+
body := injectLiveReload(string(b))
80+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
81+
w.Header().Set("Cache-Control", "no-store")
82+
_, _ = w.Write([]byte(body))
83+
return
84+
}
85+
w.Header().Set("Cache-Control", "no-store")
86+
http.ServeFile(w, r, full)
87+
}
88+
}
89+
90+
const liveReloadScript = `<script>
91+
(function () {
92+
var es = new EventSource("/__livereload");
93+
es.onmessage = function (e) { if (e.data === "reload") location.reload(); };
94+
})();
95+
</script>`
96+
97+
func injectLiveReload(html string) string {
98+
if i := strings.LastIndex(html, "</body>"); i >= 0 {
99+
return html[:i] + liveReloadScript + html[i:]
100+
}
101+
return html + liveReloadScript
102+
}
103+
104+
// liveReloadHandler streams a reload event whenever the static dir changes.
105+
// It polls a cheap fingerprint (file count + sizes + max mtime) so it needs no
106+
// third-party file-watching dependency.
107+
func liveReloadHandler(dir string) http.HandlerFunc {
108+
return func(w http.ResponseWriter, r *http.Request) {
109+
flusher, ok := w.(http.Flusher)
110+
if !ok {
111+
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
112+
return
113+
}
114+
w.Header().Set("Content-Type", "text/event-stream")
115+
w.Header().Set("Cache-Control", "no-cache")
116+
w.Header().Set("Connection", "keep-alive")
117+
118+
last := fingerprint(dir)
119+
ticker := time.NewTicker(400 * time.Millisecond)
120+
defer ticker.Stop()
121+
for {
122+
select {
123+
case <-r.Context().Done():
124+
return
125+
case <-ticker.C:
126+
if fp := fingerprint(dir); fp != last {
127+
last = fp
128+
fmt.Fprint(w, "data: reload\n\n")
129+
flusher.Flush()
130+
}
131+
}
132+
}
133+
}
134+
}
135+
136+
// fingerprint returns a string that changes whenever a file under dir is added,
137+
// removed, resized, or modified.
138+
func fingerprint(dir string) string {
139+
var b strings.Builder
140+
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
141+
if err != nil || d.IsDir() {
142+
return nil
143+
}
144+
if info, err := d.Info(); err == nil {
145+
fmt.Fprintf(&b, "%s:%d:%d;", path, info.Size(), info.ModTime().UnixNano())
146+
}
147+
return nil
148+
})
149+
return b.String()
150+
}
151+
152+
func dirExists(p string) bool {
153+
info, err := os.Stat(p)
154+
return err == nil && info.IsDir()
155+
}
156+
157+
func normalizeAddr(addr string) string {
158+
if strings.HasPrefix(addr, ":") {
159+
return addr
160+
}
161+
return ":" + addr
162+
}

docs/getting-started.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,10 @@ The UI loads without a login and can mint and revoke keys for any model the
218218
in the Helm chart); it is off by default and must never be enabled in a real
219219
deployment.
220220

221+
> **Working on the UI itself?** Use `make run-dev` instead of the steps above:
222+
> one command brings up the cluster, three models, the port-forward, and a
223+
> hot-reloading dev server. See [ui-development.md](ui-development.md).
224+
221225
## 11. Cleanup
222226

223227
When you are done, delete the kind cluster:

0 commit comments

Comments
 (0)