From 0b003f11209fef0d0956a1fdbcc470896c777f0c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 12:15:07 +0000 Subject: [PATCH] feat: embedded web console with the Pro look, optional and zero-dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a small management console served by the gateway at / — Dashboard (health, local models, usage/spend, client snippets), Models (catalog + provider keys), Preferences, Playground (recommend + streaming chat), and Interaction History. Plain HTML/CSS/JS embedded via go:embed: no build step, no external assets, single-binary story unchanged, and the visual language matches the Route42 Pro desktop app (dark gray surfaces, blue-to-purple gradients, Inter). The console is optional: server.ui (default true, env ROUTE42_UI) disables it, and the CLI + HTTP API work identically either way. The auth middleware now guards exactly /api/* and /v1/* so the static assets are public like /health; the console stores an entered token and sends it on API calls. Also: - GET /api/interactions exposes the existing local interaction log (limit param, default 50, max 500) for the history page. - config.Prefs gains snake_case json tags. This fixes the documented wire format: /api/prefs previously emitted Go field names, and `prefs set --json '{"fallback_depth":3}'` silently ignored underscored keys. Storage is column-based SQLite, so existing databases are unaffected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JgWPXefHtzYcspK8zo4Bo6 --- README.md | 21 +- cmd/route42/serve.go | 3 + docs/config.md | 5 +- internal/api/interactions.go | 31 ++ internal/api/routes.go | 16 +- internal/api/server.go | 6 +- internal/api/webui_test.go | 126 ++++++ internal/config/config.go | 32 +- internal/webui/static/app.js | 716 +++++++++++++++++++++++++++++++ internal/webui/static/index.html | 97 +++++ internal/webui/static/logo.svg | 8 + internal/webui/static/style.css | 489 +++++++++++++++++++++ internal/webui/webui.go | 27 ++ 13 files changed, 1559 insertions(+), 18 deletions(-) create mode 100644 internal/api/interactions.go create mode 100644 internal/api/webui_test.go create mode 100644 internal/webui/static/app.js create mode 100644 internal/webui/static/index.html create mode 100644 internal/webui/static/logo.svg create mode 100644 internal/webui/static/style.css create mode 100644 internal/webui/webui.go diff --git a/README.md b/README.md index d6f8e9c..7210f1a 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ A fourth mode — analyzers trained on real routing outcomes — ships with [Rou - **Bring your own keys** — per-provider API keys stored encrypted in a local SQLite database. - **No telemetry** — nothing leaves your machine except the LLM calls you route. - **Interaction log & stats** — every request records the chosen model, rationale, cost, and latency; browse usage and spend locally. -- **Single binary** — Go backend, SQLite storage, no external services. +- **Web console** — a built-in dashboard at `http://localhost:4242` for usage, models, keys, preferences, and a routing playground. Optional: everything works headless via the CLI and API (`server.ui: false` turns it off). +- **Single binary** — Go backend, SQLite storage, embedded UI, no external services. ## Install @@ -82,7 +83,7 @@ go build -o route42 ./cmd/route42 ```bash # 1. Run -route42 serve # starts on localhost:4242 +route42 serve # gateway + web console on localhost:4242 # 2. Add a provider key (or none — Ollama-only works fine) curl -X POST localhost:4242/api/keys -d '{"provider":"openai","api_key":"sk-..."}' @@ -95,6 +96,18 @@ curl localhost:4242/api/chat/completions \ Point any OpenAI client at `http://localhost:4242` and it just works. +## Web console + +`route42 serve` also hosts a small web console at [http://localhost:4242](http://localhost:4242) — the same look as the Route42 Pro desktop app, embedded in the binary (plain HTML/CSS/JS, no build step, no external assets): + +- **Dashboard** — gateway health, discovered local models, usage/spend/tokens over time, per-model and per-category breakdowns, copy-paste client snippets. +- **Models** — the full routable catalog with quality/price metrics and availability, plus provider key management. +- **Preferences** — routing priority, model filters, cost/latency limits, and fallback, with a live profile summary. +- **Playground** — type a prompt, see the full routing decision (complexity, category, ranked candidates), and run it with streaming output. +- **Interaction History** — the local request log: model, category, tokens, cost, latency. + +The console is optional. The gateway is fully operable headless via the CLI and HTTP API; set `server.ui: false` (or `ROUTE42_UI=false`) to disable it. If `server.api_token` is set, the console prompts for the token and sends it on its API calls. + ## CLI The `route42` binary is both the gateway and a management tool: @@ -234,10 +247,12 @@ Use base URL `http://localhost:4242/v1`, any non-empty API key (unless you set ` | `PUT` | `/api/prefs` | Replace preferences (validated). | | `POST` | `/api/recommend` | Ranked candidates + explanation, no execution (`{"messages":[...]}`). | | `GET` | `/api/stats?days=N` | Usage aggregates (0 = all time). | +| `GET` | `/api/interactions?limit=N` | Recent interaction log entries, newest first (default 50, max 500). | | `GET` | `/api/models` *(alias `/v1/models`)* | Catalog + local discovery + availability (OpenAI list shape). | | `GET` | `/health` | Liveness/readiness (always public). | +| `GET` | `/` | Embedded web console (disable with `server.ui: false`). | -Optional auth: set `server.api_token` in config and send `Authorization: Bearer ` on `/api/*` routes (`/health` stays public). +Optional auth: set `server.api_token` in config and send `Authorization: Bearer ` on `/api/*` routes (`/health` and the console assets stay public). ## Documentation diff --git a/cmd/route42/serve.go b/cmd/route42/serve.go index 52c3c10..c39fed1 100644 --- a/cmd/route42/serve.go +++ b/cmd/route42/serve.go @@ -38,6 +38,9 @@ func runServe(args []string) error { defer stop() env.logger.Info("route42 starting", "version", version, "port", env.cfg.Server.Port, "analyzer", env.cfg.Analyzer.Mode) + if env.cfg.Server.UI { + env.logger.Info("web console enabled", "url", fmt.Sprintf("http://localhost:%d", env.cfg.Server.Port)) + } if err := srv.Run(ctx); err != nil { return fmt.Errorf("serve: %w", err) } diff --git a/docs/config.md b/docs/config.md index 97f546f..b52a6ae 100644 --- a/docs/config.md +++ b/docs/config.md @@ -18,6 +18,7 @@ run creates the database and works Ollama-only with no keys). server: port: 4242 # 1..65535 api_token: "" # optional bearer token for /api/* (empty = no auth) + ui: true # serve the embedded web console at / analyzer: mode: heuristic # heuristic | llm | hybrid @@ -54,7 +55,8 @@ prefs: # initial routing preferences (first-run seed) | Field | Default | Validation | Notes | |---|---|---|---| | `port` | `4242` | 1–65535 | TCP port the gateway listens on. | -| `api_token` | `""` | — | When set, `/api/*` and `/v1/*` require `Authorization: Bearer `. `/health` is always public. Empty (default) = no auth, for local single-user use. | +| `api_token` | `""` | — | When set, `/api/*` and `/v1/*` require `Authorization: Bearer `. `/health` and the web console assets are always public (the console asks for the token and sends it on its API calls). Empty (default) = no auth, for local single-user use. | +| `ui` | `true` | — | Serves the embedded web console at `/`. Set `false` for a headless gateway; the CLI and HTTP API are unaffected either way. | ### `analyzer` @@ -116,6 +118,7 @@ All `ROUTE42_*` variables override file values. Provider keys use |---|---| | `ROUTE42_PORT` | `server.port` | | `ROUTE42_API_TOKEN` | `server.api_token` | +| `ROUTE42_UI` | `server.ui` | | `ROUTE42_ANALYZER_MODE` | `analyzer.mode` | | `ROUTE42_ANALYZER_LLM_MODEL` | `analyzer.llm.model` | | `ROUTE42_ANALYZER_LLM_TIMEOUT_MS` | `analyzer.llm.timeout_ms` | diff --git a/internal/api/interactions.go b/internal/api/interactions.go new file mode 100644 index 0000000..9d1259e --- /dev/null +++ b/internal/api/interactions.go @@ -0,0 +1,31 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/krugis/route42app/internal/store" +) + +// handleInteractions returns the most recent interaction records, newest +// first. The `limit` query parameter (default 50, max 500) bounds the page. +func (s *Server) handleInteractions(w http.ResponseWriter, r *http.Request) { + limit := 50 + if l := r.URL.Query().Get("limit"); l != "" { + if v, err := strconv.Atoi(l); err == nil && v > 0 { + limit = v + } + } + if limit > 500 { + limit = 500 + } + interactions, err := s.store.RecentInteractions(limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "load interactions: "+err.Error(), "server_error") + return + } + if interactions == nil { + interactions = []store.Interaction{} + } + writeJSON(w, http.StatusOK, map[string]any{"interactions": interactions}) +} diff --git a/internal/api/routes.go b/internal/api/routes.go index a1cac8c..02f0192 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -1,6 +1,10 @@ package api -import "net/http" +import ( + "net/http" + + "github.com/krugis/route42app/internal/webui" +) // registerRoutes wires every Route42 endpoint onto the ServeMux. /v1/* // mirror /api/* so any OpenAI SDK pointed at the gateway works unchanged. @@ -22,8 +26,9 @@ func (s *Server) registerRoutes(mux *http.ServeMux) { // Routing recommendation (no execution). mux.HandleFunc("POST /api/recommend", s.handleRecommend) - // Usage stats. + // Usage stats and the interaction log. mux.HandleFunc("GET /api/stats", s.handleStats) + mux.HandleFunc("GET /api/interactions", s.handleInteractions) // Models list (catalog + local discovery + availability). mux.HandleFunc("GET /api/models", s.handleModels) @@ -31,4 +36,11 @@ func (s *Server) registerRoutes(mux *http.ServeMux) { // Health (always public). mux.HandleFunc("GET /health", s.handleHealth) + + // Embedded web console (optional; server.ui). Registered on the GET / + // catch-all so every API route above still wins. The gateway is fully + // usable without it via the CLI and the HTTP API. + if s.cfg.Server.UI { + mux.Handle("GET /", webui.Handler()) + } } diff --git a/internal/api/server.go b/internal/api/server.go index 060072f..9468018 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -184,14 +184,16 @@ func (s *Server) Handler() http.Handler { } // withAuth applies the optional static-token auth to /api and /v1 routes -// when server.api_token is set. /health is always public. +// when server.api_token is set. /health and the static web console assets +// stay public — the console holds no data of its own and calls the guarded +// /api routes with the token the user enters. func (s *Server) withAuth(h http.Handler) http.Handler { token := s.cfg.Server.APIToken if token == "" { return h } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/health") { + if !strings.HasPrefix(r.URL.Path, "/api/") && !strings.HasPrefix(r.URL.Path, "/v1/") { h.ServeHTTP(w, r) return } diff --git a/internal/api/webui_test.go b/internal/api/webui_test.go new file mode 100644 index 0000000..f2cd93c --- /dev/null +++ b/internal/api/webui_test.go @@ -0,0 +1,126 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/krugis/route42app/internal/config" + "github.com/krugis/route42app/internal/store" +) + +// newUITestServer builds a Server on a temp store with the given config +// mutations applied to the defaults. +func newUITestServer(t *testing.T, mutate func(*config.Config)) *httptest.Server { + t.Helper() + cfg := config.Default() + cfg.DB.Path = filepath.Join(t.TempDir(), "test.db") + if mutate != nil { + mutate(cfg) + } + st, err := store.Open(cfg.DB.Path) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { st.Close() }) + if err := st.EnsurePrefs(cfg.Prefs); err != nil { + t.Fatalf("seed prefs: %v", err) + } + srv, err := New(cfg, st, Options{}) + if err != nil { + t.Fatalf("build server: %v", err) + } + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + return ts +} + +func get(t *testing.T, url, token string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + t.Cleanup(func() { res.Body.Close() }) + return res +} + +func TestWebUIServedAtRoot(t *testing.T) { + ts := newUITestServer(t, nil) // UI defaults to on + + res := get(t, ts.URL+"/", "") + if res.StatusCode != http.StatusOK { + t.Fatalf("GET /: status = %d, want 200", res.StatusCode) + } + if ct := res.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Fatalf("GET /: content-type = %q, want text/html", ct) + } + + for _, asset := range []string{"/app.js", "/style.css", "/logo.svg"} { + if res := get(t, ts.URL+asset, ""); res.StatusCode != http.StatusOK { + t.Errorf("GET %s: status = %d, want 200", asset, res.StatusCode) + } + } +} + +func TestWebUIDisabled(t *testing.T) { + ts := newUITestServer(t, func(c *config.Config) { c.Server.UI = false }) + + if res := get(t, ts.URL+"/", ""); res.StatusCode != http.StatusNotFound { + t.Fatalf("GET / with ui disabled: status = %d, want 404", res.StatusCode) + } + // The API is unaffected. + if res := get(t, ts.URL+"/health", ""); res.StatusCode != http.StatusOK { + t.Fatalf("GET /health with ui disabled: status = %d, want 200", res.StatusCode) + } + if res := get(t, ts.URL+"/api/prefs", ""); res.StatusCode != http.StatusOK { + t.Fatalf("GET /api/prefs with ui disabled: status = %d, want 200", res.StatusCode) + } +} + +func TestWebUIAuthScope(t *testing.T) { + ts := newUITestServer(t, func(c *config.Config) { c.Server.APIToken = "secret" }) + + // Static console and health stay public. + if res := get(t, ts.URL+"/", ""); res.StatusCode != http.StatusOK { + t.Fatalf("GET / with auth on: status = %d, want 200", res.StatusCode) + } + if res := get(t, ts.URL+"/health", ""); res.StatusCode != http.StatusOK { + t.Fatalf("GET /health with auth on: status = %d, want 200", res.StatusCode) + } + // API routes still require the token. + if res := get(t, ts.URL+"/api/prefs", ""); res.StatusCode != http.StatusUnauthorized { + t.Fatalf("GET /api/prefs without token: status = %d, want 401", res.StatusCode) + } + if res := get(t, ts.URL+"/api/prefs", "secret"); res.StatusCode != http.StatusOK { + t.Fatalf("GET /api/prefs with token: status = %d, want 200", res.StatusCode) + } +} + +func TestInteractionsEndpoint(t *testing.T) { + ts := newUITestServer(t, nil) + + res := get(t, ts.URL+"/api/interactions?limit=10", "") + if res.StatusCode != http.StatusOK { + t.Fatalf("GET /api/interactions: status = %d, want 200", res.StatusCode) + } + var body struct { + Interactions []store.Interaction `json:"interactions"` + } + if err := json.NewDecoder(res.Body).Decode(&body); err != nil { + t.Fatalf("decode interactions: %v", err) + } + if body.Interactions == nil { + t.Fatal("interactions should be an empty array, not null") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 02a3ffa..ba34458 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,9 @@ type Server struct { // APIToken, when set, requires "Authorization: Bearer " on all // /api endpoints. Empty (default) means no auth: local single-user use. APIToken string `yaml:"api_token"` + // UI serves the embedded web console at "/" (default true). The gateway + // works identically without it via the CLI and the HTTP API. + UI bool `yaml:"ui"` } // Analyzer selects and configures the prompt analyzer. @@ -75,15 +78,17 @@ type Provider struct { // After that, preferences live in the database (editable via /api/prefs). type Prefs struct { // Priority is one of balanced, fast, cheap, accurate. - Priority string `yaml:"priority"` - MaxCostCents float64 `yaml:"max_cost_cents"` - LatencyToleranceMs int `yaml:"latency_tolerance_ms"` - OnlyFree bool `yaml:"only_free"` - OnlyLocal bool `yaml:"only_local"` - MaxResponseTokens int `yaml:"max_response_tokens"` - DefaultModel string `yaml:"default_model"` - FallbackDepth int `yaml:"fallback_depth"` - DisallowedModels []string `yaml:"disallowed_models"` + // The json tags define the /api/prefs and `prefs set --json` wire + // format; they intentionally mirror the yaml names. + Priority string `yaml:"priority" json:"priority"` + MaxCostCents float64 `yaml:"max_cost_cents" json:"max_cost_cents"` + LatencyToleranceMs int `yaml:"latency_tolerance_ms" json:"latency_tolerance_ms"` + OnlyFree bool `yaml:"only_free" json:"only_free"` + OnlyLocal bool `yaml:"only_local" json:"only_local"` + MaxResponseTokens int `yaml:"max_response_tokens" json:"max_response_tokens"` + DefaultModel string `yaml:"default_model" json:"default_model"` + FallbackDepth int `yaml:"fallback_depth" json:"fallback_depth"` + DisallowedModels []string `yaml:"disallowed_models" json:"disallowed_models"` } // Analyzer modes. @@ -100,7 +105,7 @@ var validPriorities = []string{"balanced", "fast", "cheap", "accurate"} // with only a local Ollama installation. func Default() *Config { return &Config{ - Server: Server{Port: 4242}, + Server: Server{Port: 4242, UI: true}, Analyzer: Analyzer{ Mode: ModeHeuristic, LLM: AnalyzerLLM{Model: "qwen2.5:0.5b", TimeoutMs: 1500, HybridWeight: 0.5}, @@ -171,6 +176,13 @@ func (c *Config) applyEnv() error { if v := os.Getenv("ROUTE42_API_TOKEN"); v != "" { c.Server.APIToken = v } + if v := os.Getenv("ROUTE42_UI"); v != "" { + on, err := strconv.ParseBool(v) + if err != nil { + return fmt.Errorf("ROUTE42_UI: %q is not a boolean", v) + } + c.Server.UI = on + } if v := os.Getenv("ROUTE42_ANALYZER_MODE"); v != "" { c.Analyzer.Mode = v } diff --git a/internal/webui/static/app.js b/internal/webui/static/app.js new file mode 100644 index 0000000..dbe1846 --- /dev/null +++ b/internal/webui/static/app.js @@ -0,0 +1,716 @@ +/* Route42 Community Console — zero-dependency SPA over the gateway's own API. */ +(() => { + 'use strict'; + + const $ = (sel, el = document) => el.querySelector(sel); + const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const icon = (id, cls = 'icon-sm') => ``; + + // ---------- API plumbing (optional bearer token, kept in localStorage) ---------- + let token = localStorage.getItem('route42_token') || ''; + + async function api(path, opts = {}) { + const headers = { ...(opts.body ? { 'Content-Type': 'application/json' } : {}), ...(opts.headers || {}) }; + if (token) headers['Authorization'] = 'Bearer ' + token; + const res = await fetch(path, { ...opts, headers }); + if (res.status === 401) { + $('#authbar').classList.remove('hidden'); + throw new Error('Unauthorized — enter the gateway API token above.'); + } + if (!res.ok) { + let msg = `${res.status} ${res.statusText}`; + try { const j = await res.json(); if (j.error?.message) msg = j.error.message; } catch { /* keep default */ } + throw new Error(msg); + } + return res; + } + const getJSON = async (path) => (await api(path)).json(); + + $('#token-save').addEventListener('click', () => { + token = $('#token-input').value.trim(); + localStorage.setItem('route42_token', token); + $('#authbar').classList.add('hidden'); + TABS[activeTab].load(); + toast('Token saved'); + }); + + function showError(msg) { + const bar = $('#errorbar'); + bar.textContent = msg; + bar.classList.remove('hidden'); + } + function clearError() { $('#errorbar').classList.add('hidden'); } + + let toastTimer; + function toast(msg) { + const t = $('#toast'); + t.textContent = msg; + t.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => t.classList.remove('show'), 2200); + } + function copyText(text, msg = 'Copied to clipboard') { + navigator.clipboard?.writeText(text).then(() => toast(msg), () => toast('Copy failed')); + } + + // ---------- formatting ---------- + const fmtInt = (n) => Number(n || 0).toLocaleString(); + const fmtCents = (c) => { + const v = Number(c || 0); + if (v === 0) return '$0.00'; + const usd = v / 100; + return '$' + (usd < 0.01 ? usd.toFixed(5) : usd.toFixed(2)); + }; + const fmtScore = (v) => (v || v === 0 ? Number(v).toFixed(2) : '—'); + const fmtPrice = (v) => (v ? '$' + Number(v).toFixed(2) : '—'); + + // ---------- tabs ---------- + const TABS = { + dashboard: { title: 'Dashboard', sub: 'Monitor your AI usage and performance', load: loadDashboard, range: true }, + models: { title: 'Models', sub: 'Browse routable models and manage provider keys', load: loadModels }, + preferences: { title: 'Preferences', sub: 'Configure routing behavior', load: loadPreferences }, + playground: { title: 'Playground', sub: 'Test prompt routing', load: loadPlayground }, + history: { title: 'Interaction History', sub: 'Recent routed requests and their decisions', load: loadHistory }, + }; + let activeTab = 'dashboard'; + + function switchTab(tab) { + if (!TABS[tab]) tab = 'dashboard'; + activeTab = tab; + clearError(); + for (const btn of $('#nav').children) btn.classList.toggle('active', btn.dataset.tab === tab); + for (const key of Object.keys(TABS)) $('#page-' + key).classList.toggle('hidden', key !== tab); + $('#page-title').textContent = TABS[tab].title; + $('#page-sub').textContent = TABS[tab].sub; + $('#range').classList.toggle('hidden', !TABS[tab].range); + if (location.hash !== '#' + tab) history.replaceState(null, '', '#' + tab); + TABS[tab].load(); + } + $('#nav').addEventListener('click', (e) => { + const btn = e.target.closest('button[data-tab]'); + if (btn) switchTab(btn.dataset.tab); + }); + window.addEventListener('hashchange', () => switchTab(location.hash.slice(1))); + + // ---------- Dashboard ---------- + const RANGES = [ + { label: 'Today', days: 1 }, + { label: 'Week', days: 7 }, + { label: 'Month', days: 30 }, + { label: '3 Months', days: 90 }, + { label: 'All', days: 0 }, + ]; + let rangeDays = 30; + + $('#range').innerHTML = RANGES.map((r) => + ``).join(''); + $('#range').addEventListener('click', (e) => { + const btn = e.target.closest('button[data-days]'); + if (!btn) return; + rangeDays = Number(btn.dataset.days); + for (const b of $('#range').children) b.classList.toggle('active', b === btn); + loadStats(); + }); + + const base = location.origin; + const SNIPPETS = { + curl: `curl ${base}/api/chat/completions \\ + -H "Content-Type: application/json" \\ + -d '{"messages":[{"role":"user","content":"Explain quantum computing to a 10-year-old"}]}'`, + python: `from openai import OpenAI + +client = OpenAI(base_url="${base}/v1", api_key="unused") + +resp = client.chat.completions.create( + model="auto", # Route42 picks the model + messages=[{"role": "user", "content": "Explain quantum computing to a 10-year-old"}], +) +print(resp.choices[0].message.content) +print(resp.model) # the model Route42 picked`, + javascript: `import OpenAI from "openai"; + +const client = new OpenAI({ baseURL: "${base}/v1", apiKey: "unused" }); + +const resp = await client.chat.completions.create({ + model: "auto", + messages: [{ role: "user", content: "Explain quantum computing to a 10-year-old" }], +}); +console.log(resp.choices[0].message.content);`, + }; + + function dashboardSkeleton() { + $('#page-dashboard').innerHTML = ` + + +
+
+
+
+ ${icon('i-server', 'icon c-blue')} +

Backend Health

Live health endpoint from the router

+
+ +
+
Checking…
+
+
+ +
+
+
+ ${icon('i-cpu', 'icon c-emerald')} +

Local LLM

Ollama models discovered by the router

+
+ +
+
Checking…
+
+
+
+ +
+ +
+
+
${icon('i-chart', 'icon c-purple')}

Usage by model

+
No usage yet.
+
+
+
${icon('i-activity', 'icon c-amber')}

Prompts by category

+
No usage yet.
+
+
+ +
+
+
+ ${icon('i-terminal', 'icon c-blue')} +

Client API (one endpoint, auto-selects model)

+

Send OpenAI-compatible requests; Route42 routes to local or cloud and answers in the OpenAI shape.

+
+ +
+
${esc(base)}/v1/chat/completions +
+
+
+ + + +
+

+        
+
`; + + let lang = 'curl'; + const renderSnippet = () => { $('#snippet-body').textContent = SNIPPETS[lang]; }; + renderSnippet(); + $('#snippet-tabs').addEventListener('click', (e) => { + const btn = e.target.closest('button[data-lang]'); + if (!btn) return; + lang = btn.dataset.lang; + for (const b of $('#snippet-tabs').children) b.classList.toggle('active', b === btn); + renderSnippet(); + }); + $('#btn-copy-snippet').addEventListener('click', () => copyText(SNIPPETS[lang])); + $('#btn-copy-endpoint').addEventListener('click', () => copyText(base + '/v1/chat/completions', 'Endpoint copied')); + $('#btn-health').addEventListener('click', loadHealth); + $('#btn-local').addEventListener('click', loadLocal); + } + + async function loadHealth() { + const body = $('#health-body'), meta = $('#health-meta'); + if (!body) return; + try { + const h = await getJSON('/health'); + const ok = h.status === 'ok'; + body.innerHTML = `${ok ? 'Healthy' : esc(h.status)} + Checked ${new Date().toLocaleTimeString()}`; + meta.textContent = `v${h.version} · analyzer: ${h.analyzer} · catalog: ${h.catalog_models} models · up ${h.uptime}`; + } catch (err) { + body.innerHTML = `Down`; + meta.textContent = err.message; + } + } + + async function loadLocal() { + const body = $('#local-body'), meta = $('#local-meta'); + if (!body) return; + try { + const res = await getJSON('/api/models'); + const locals = (res.data || []).filter((m) => m.x_route42?.source === 'local'); + if (locals.length) { + body.innerHTML = `Local ready`; + meta.innerHTML = `${locals.length} local model${locals.length === 1 ? '' : 's'} detected — ` + + locals.slice(0, 4).map((m) => `${esc(m.id)}`).join(' ') + + (locals.length > 4 ? ` +${locals.length - 4} more` : ''); + } else { + body.innerHTML = `No local models`; + meta.textContent = 'Ollama not detected (or has no models). Cloud routing still works with provider keys.'; + } + } catch (err) { + body.innerHTML = `Unavailable`; + meta.textContent = err.message; + } + } + + async function loadStats() { + const tiles = $('#stat-tiles'); + if (!tiles) return; + try { + const s = await getJSON('/api/stats?days=' + rangeDays); + const saved = (s.by_model || []).filter((m) => m.provider === 'ollama').reduce((acc, m) => acc + m.requests, 0); + tiles.innerHTML = ` +
${icon('i-activity', 'icon-sm c-blue')} Requests
${fmtInt(s.requests)}
+
${fmtInt(s.errors)} error${s.errors === 1 ? '' : 's'}
+
${icon('i-zap', 'icon-sm c-amber')} Tokens
${fmtInt(s.total_tokens)}
+
prompt + completion
+
${icon('i-dollar', 'icon-sm c-emerald')} Spend
${fmtCents(s.cost_cents)}
+
estimated, cloud only
+
${icon('i-cpu', 'icon-sm c-purple')} Local requests
${fmtInt(saved)}
+
served at $0.00
`; + + const bars = (rows, labelOf, valueOf, valText) => { + if (!rows.length) return 'No usage in this window.'; + const max = Math.max(...rows.map(valueOf)) || 1; + return rows.map((r) => ` +
+ ${esc(labelOf(r))} +
+ ${valText(r)} +
`).join(''); + }; + $('#by-model').innerHTML = bars((s.by_model || []).slice(0, 8), (m) => m.model, (m) => m.requests, + (m) => `${fmtInt(m.requests)}`); + const cats = Object.entries(s.by_category || {}).sort((a, b) => b[1] - a[1]); + $('#by-category').innerHTML = bars(cats, (c) => c[0], (c) => c[1], (c) => fmtInt(c[1])); + } catch (err) { + showError('Stats: ' + err.message); + } + } + + function loadDashboard() { + dashboardSkeleton(); + loadHealth(); + loadLocal(); + loadStats(); + } + + // ---------- Models ---------- + let allModels = []; + + async function loadModels() { + const page = $('#page-models'); + page.innerHTML = ` +
+
+
${icon('i-cpu', 'icon c-blue')}

Routable models

+

Catalog + discovered local models. “Available” means the provider has a key or the model is local.

+
+ + +
+
+
+ + +
ModelProviderSourceQuality$/M in$/M outToolsAvailable
Loading…
+
+ +
+
+
${icon('i-key', 'icon c-amber')}

Provider keys

+

Stored encrypted in the local database. Values are write-only and shown masked.

+
+
+ + + +
+
Loading…
+
`; + + $('#model-search').addEventListener('input', renderModelRows); + $('#model-avail').addEventListener('change', renderModelRows); + $('#key-add').addEventListener('click', addKey); + + try { + const res = await getJSON('/api/models'); + allModels = res.data || []; + renderModelRows(); + } catch (err) { + $('#model-rows').innerHTML = `${esc(err.message)}`; + } + loadKeys(); + } + + function renderModelRows() { + const q = ($('#model-search')?.value || '').toLowerCase(); + const availOnly = $('#model-avail')?.checked; + const rows = allModels.filter((m) => { + const x = m.x_route42 || {}; + if (availOnly && !x.available) return false; + return !q || m.id.toLowerCase().includes(q) || (x.provider || '').toLowerCase().includes(q); + }); + $('#model-rows').innerHTML = rows.length ? rows.map((m) => { + const x = m.x_route42 || {}; + return ` + ${esc(m.id)} + ${esc(x.provider || m.owned_by)} + ${esc(x.source || 'cloud')} + ${fmtScore(x.quality_score)} + ${fmtPrice(x.input_price_per_mtok)} + ${fmtPrice(x.output_price_per_mtok)} + ${x.supports_tools ? '✓' : '—'} + + `; + }).join('') : 'No models match.'; + } + + async function loadKeys() { + const list = $('#key-list'); + if (!list) return; + try { + const res = await getJSON('/api/keys'); + const providers = res.providers || []; + list.innerHTML = providers.length ? `
` + providers.map((p) => ` + + + `).join('') + `
${esc(p.provider)}${esc(p.key_mask || '••••••••')} + +
` + : 'No provider keys configured. Ollama-only routing works without any.'; + list.querySelectorAll('button[data-del]').forEach((btn) => btn.addEventListener('click', async () => { + try { + await api('/api/keys?provider=' + encodeURIComponent(btn.dataset.del), { method: 'DELETE' }); + toast(`Removed ${btn.dataset.del} key`); + loadKeys(); + getJSON('/api/models').then((r) => { allModels = r.data || []; renderModelRows(); }).catch(() => {}); + } catch (err) { showError(err.message); } + })); + } catch (err) { + list.textContent = err.message; + } + } + + async function addKey() { + const provider = $('#key-provider').value; + const key = $('#key-value').value.trim(); + if (!key) { showError('Enter an API key first.'); return; } + try { + clearError(); + await api('/api/keys', { method: 'POST', body: JSON.stringify({ provider, api_key: key }) }); + $('#key-value').value = ''; + toast(`Saved ${provider} key`); + loadKeys(); + const r = await getJSON('/api/models'); + allModels = r.data || []; + renderModelRows(); + } catch (err) { showError(err.message); } + } + + // ---------- Preferences ---------- + const PRIORITIES = [ + { id: 'balanced', name: 'Balanced', desc: 'Optimal balance', icon: 'i-activity', color: 'c-blue', guide: 'Optimizes across quality, speed, and cost.' }, + { id: 'fast', name: 'Fast', desc: 'Lowest latency', icon: 'i-zap', color: 'c-amber', guide: 'Prefers the lowest-latency qualified models.' }, + { id: 'accurate', name: 'Accurate', desc: 'Best quality', icon: 'i-sparkles', color: 'c-purple', guide: 'Prefers the highest quality outputs.' }, + { id: 'cheap', name: 'Cheap', desc: 'Minimum cost', icon: 'i-dollar', color: 'c-emerald', guide: 'Picks the cheapest model that can handle the prompt.' }, + ]; + let prefs = null; + + async function loadPreferences() { + const page = $('#page-preferences'); + page.innerHTML = '
Loading preferences…
'; + try { + prefs = await getJSON('/api/prefs'); + } catch (err) { + page.innerHTML = `
${esc(err.message)}
`; + return; + } + + page.innerHTML = ` +
+
+
+

Routing Priority

+
+ ${PRIORITIES.map((p) => ` + `).join('')} +
+
+ +
+

Model Filters

+
+ + +
+
+ +
+

Limits & Fallback

+

Zero means no limit.

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+ +
+
+

${icon('i-settings', 'icon-sm c-blue')} Active Profile

+
+
+
+

Priority Guide

+ ${PRIORITIES.map((p) => ` +
${icon(p.icon, 'icon-sm ' + p.color)} ${p.name}
+
${p.guide}
`).join('')} +
+
+
`; + + renderProfile(); + $('#prio-grid').addEventListener('click', (e) => { + const btn = e.target.closest('button[data-prio]'); + if (!btn) return; + prefs.priority = btn.dataset.prio; + for (const b of $('#prio-grid').children) b.classList.toggle('active', b === btn); + renderProfile(); + }); + for (const id of ['pf-only-free', 'pf-only-local', 'pf-max-cost', 'pf-latency', 'pf-max-tokens', 'pf-fallback', 'pf-default', 'pf-disallowed']) { + $('#' + id).addEventListener('input', () => { collectPrefs(); renderProfile(); }); + } + $('#pf-save').addEventListener('click', savePrefs); + } + + function collectPrefs() { + prefs.only_free = $('#pf-only-free').checked; + prefs.only_local = $('#pf-only-local').checked; + prefs.max_cost_cents = Number($('#pf-max-cost').value) || 0; + prefs.latency_tolerance_ms = Number($('#pf-latency').value) || 0; + prefs.max_response_tokens = Number($('#pf-max-tokens').value) || 0; + prefs.fallback_depth = Number($('#pf-fallback').value) || 0; + prefs.default_model = $('#pf-default').value.trim(); + prefs.disallowed_models = $('#pf-disallowed').value.split(',').map((s) => s.trim()).filter(Boolean); + } + + function renderProfile() { + const inf = ''; + const filters = [prefs.only_free && 'Free only', prefs.only_local && 'Local only'].filter(Boolean).join(', ') || 'None'; + $('#profile-body').innerHTML = ` +
Priority${esc(cap(prefs.priority))}
+
Max Cost${prefs.max_cost_cents ? prefs.max_cost_cents + '¢' : inf}
+
Latency Limit${prefs.latency_tolerance_ms ? prefs.latency_tolerance_ms + ' ms' : inf}
+
Max Response${prefs.max_response_tokens ? fmtInt(prefs.max_response_tokens) : inf}
+
Fallback Depth${prefs.fallback_depth ?? 0}
+
Filters${esc(filters)}
+
Default Model${esc(prefs.default_model || 'auto')}
`; + } + const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : ''); + + async function savePrefs() { + collectPrefs(); + const status = $('#pf-status'); + try { + clearError(); + const res = await api('/api/prefs', { method: 'PUT', body: JSON.stringify(prefs) }); + prefs = await res.json(); + status.textContent = 'Saved ✓'; + toast('Preferences saved'); + setTimeout(() => { status.textContent = ''; }, 2500); + } catch (err) { + showError('Save failed: ' + err.message); + } + } + + // ---------- Playground ---------- + function loadPlayground() { + const page = $('#page-playground'); + if (page.dataset.ready) return; // keep state between tab switches + page.dataset.ready = '1'; + page.innerHTML = ` +
+
${icon('i-play', 'icon c-blue')} +

Prompt

Recommend explains the routing decision without executing; Send routes and runs it.

+ +
+ + +
+
+
+
+
${icon('i-shield', 'icon c-purple')}

Routing decision

+
Run “Recommend” or “Send” to see why Route42 picks a model.
+
+
+
${icon('i-terminal', 'icon c-emerald')}

Response

+
+
+
`; + + $('#pg-recommend').addEventListener('click', recommend); + $('#pg-send').addEventListener('click', sendChat); + } + + function renderDecision(x, candidates, explanation) { + const pct = Math.round((x.complexity || 0) * 100); + let html = ` +
+ model: ${esc(x.selected_model || '—')} + provider: ${esc(x.provider || '—')} + ${x.category ? `${esc(x.category)}` : ''} + ${x.analyzer ? `analyzer: ${esc(x.analyzer)}` : ''} + ${x.reason ? `${esc(x.reason)}` : ''} + ${x.est_cost_cents ? `~${fmtCents(x.est_cost_cents)}` : '$0.00'} +
+
Complexity
+
+
${(x.complexity || 0).toFixed(3)} · ${x.candidates_considered || 0} candidates considered
`; + + if (candidates?.length) { + html += `
+ + ` + + candidates.slice(0, 6).map((c) => ` + + + + `).join('') + '
CandidateCompositeQualitySpeedCostEst.
${esc(c.model)} ${esc(c.provider)}${fmtScore(c.composite)}${fmtScore(c.quality)}${fmtScore(c.speed)}${fmtScore(c.cost)}${fmtCents(c.est_cost_cents)}
'; + } + if (explanation) html += `
${esc(explanation)}
`; + $('#pg-decision').innerHTML = html; + } + + function promptMessages() { + const text = $('#pg-prompt').value.trim(); + if (!text) { showError('Enter a prompt first.'); return null; } + clearError(); + return [{ role: 'user', content: text }]; + } + + async function recommend() { + const messages = promptMessages(); + if (!messages) return; + $('#pg-decision').innerHTML = 'Analyzing…'; + try { + const res = await (await api('/api/recommend', { method: 'POST', body: JSON.stringify({ messages }) })).json(); + renderDecision(res.x_route42 || {}, res.candidates, res.explanation); + } catch (err) { + $('#pg-decision').innerHTML = `${esc(err.message)}`; + } + } + + async function sendChat() { + const messages = promptMessages(); + if (!messages) return; + const answer = $('#pg-answer'); + const btn = $('#pg-send'); + btn.disabled = true; + answer.classList.remove('muted'); + answer.textContent = ''; + try { + const res = await api('/api/chat/completions', { + method: 'POST', + body: JSON.stringify({ model: 'auto', messages, stream: true }), + }); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + let meta = null; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop(); + for (const line of lines) { + const data = line.startsWith('data: ') ? line.slice(6).trim() : ''; + if (!data || data === '[DONE]') continue; + try { + const chunk = JSON.parse(data); + const delta = chunk.choices?.[0]?.delta?.content; + if (delta) answer.textContent += delta; + if (chunk.x_route42) meta = chunk.x_route42; + } catch { /* partial line, ignored */ } + } + } + if (!answer.textContent) answer.textContent = '(empty response)'; + if (meta) renderDecision(meta); + } catch (err) { + answer.innerHTML = `${esc(err.message)}`; + } finally { + btn.disabled = false; + } + } + + // ---------- Interaction History ---------- + async function loadHistory() { + const page = $('#page-history'); + page.innerHTML = ` +
+
+
${icon('i-history', 'icon c-blue')}

Recent interactions

+

Newest first. Recorded locally — nothing leaves your machine.

+ +
+
+ + +
TimeModelProviderCategoryComplexityTokensCostLatencyStatus
Loading…
+
`; + $('#hist-refresh').addEventListener('click', loadHistory); + try { + const res = await getJSON('/api/interactions?limit=100'); + const rows = res.interactions || []; + $('#hist-rows').innerHTML = rows.length ? rows.map((r) => ` + ${esc(new Date(r.ts).toLocaleString())} + ${esc(r.model)} + ${esc(r.provider)} + ${r.category ? `${esc(r.category)}` : '—'} + ${(r.complexity || 0).toFixed(2)} + ${fmtInt((r.prompt_tokens || 0) + (r.completion_tokens || 0))} + ${fmtCents(r.cost_cents)} + ${fmtInt(r.latency_ms)} ms + ${r.status === 'ok' ? 'ok' : `${esc(r.status)}`} + `).join('') : 'No interactions yet — send something through the gateway.'; + } catch (err) { + $('#hist-rows').innerHTML = `${esc(err.message)}`; + } + } + + // ---------- boot ---------- + switchTab(location.hash.slice(1) || 'dashboard'); +})(); diff --git a/internal/webui/static/index.html b/internal/webui/static/index.html new file mode 100644 index 0000000..811b3c5 --- /dev/null +++ b/internal/webui/static/index.html @@ -0,0 +1,97 @@ + + + + + +Route42 — Community Console + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+

Dashboard

+

Monitor your AI usage and performance

+
+ +
+ + + + +
+
+ + + + +
+
+
+ +
+ + + diff --git a/internal/webui/static/logo.svg b/internal/webui/static/logo.svg new file mode 100644 index 0000000..64084da --- /dev/null +++ b/internal/webui/static/logo.svg @@ -0,0 +1,8 @@ + + + + + + ROUTE + 42 + diff --git a/internal/webui/static/style.css b/internal/webui/static/style.css new file mode 100644 index 0000000..6ba4d6f --- /dev/null +++ b/internal/webui/static/style.css @@ -0,0 +1,489 @@ +/* Route42 Community Console — hand-rolled port of the commercial UI theme + (dark gray-950/900 surfaces, blue→purple gradients, Inter). No framework. */ + +:root { + --gray-950: #030712; + --gray-900: #111827; + --gray-800: #1f2937; + --gray-700: #374151; + --gray-600: #4b5563; + --gray-500: #6b7280; + --gray-400: #9ca3af; + --gray-300: #d1d5db; + --blue-600: #2563eb; + --blue-500: #3b82f6; + --blue-400: #60a5fa; + --purple-600: #9333ea; + --purple-400: #c084fc; + --emerald-400: #34d399; + --green-400: #4ade80; + --amber-400: #fbbf24; + --red-400: #f87171; + --grad: linear-gradient(90deg, var(--blue-600), var(--purple-600)); +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + background: var(--gray-950); + color: #fff; + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 15px; + line-height: 1.45; +} + +button { font: inherit; color: inherit; cursor: pointer; } +button:disabled { opacity: .55; cursor: not-allowed; } +input, select, textarea { + font: inherit; + color: #fff; + background: var(--gray-800); + border: 1px solid var(--gray-700); + border-radius: 8px; + padding: 8px 12px; + outline: none; +} +input:focus, select:focus, textarea:focus { border-color: var(--blue-500); } +input::placeholder, textarea::placeholder { color: var(--gray-500); } +a { color: var(--blue-400); text-decoration: none; } +a:hover { text-decoration: underline; } +code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.hidden { display: none !important; } +.icon { width: 20px; height: 20px; flex-shrink: 0; } +.icon-sm { width: 16px; height: 16px; flex-shrink: 0; } + +.app { display: flex; height: 100vh; overflow: hidden; } + +/* ---------- Sidebar ---------- */ +.sidebar { + width: 272px; + flex-shrink: 0; + background: var(--gray-900); + border-right: 1px solid var(--gray-800); + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; +} +.sidebar::before { + content: ''; + position: absolute; + inset: 0 0 auto 0; + height: 256px; + background: linear-gradient(to bottom, rgba(37, 99, 235, .10), transparent); + pointer-events: none; +} +.sidebar > * { position: relative; z-index: 1; } + +.brand { + display: flex; + align-items: center; + gap: 12px; + padding: 22px 24px; + border-bottom: 1px solid var(--gray-800); +} +.brand img { + width: 46px; + height: 46px; + border-radius: 12px; + border: 1px solid var(--gray-700); + background: var(--gray-800); + box-shadow: 0 8px 20px rgba(59, 130, 246, .2); +} +.brand h1 { margin: 0; font-size: 20px; font-weight: 700; } +.brand p { margin: 2px 0 0; font-size: 12px; color: var(--gray-400); } + +.nav { flex: 1; padding: 16px; display: flex; flex-direction: column; gap: 4px; } +.nav button { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px 16px; + border: 0; + border-radius: 12px; + background: transparent; + color: var(--gray-400); + font-weight: 500; + text-align: left; + transition: background .15s, color .15s; +} +.nav button:hover { background: var(--gray-800); color: #fff; } +.nav button.active { + background: var(--grad); + color: #fff; + box-shadow: 0 8px 20px rgba(59, 130, 246, .2); +} +.nav button .chev { margin-left: auto; display: none; } +.nav button.active .chev { display: block; } + +.side-card { + margin: 0 16px 12px; + background: rgba(31, 41, 55, .6); + border: 1px solid var(--gray-700); + border-radius: 10px; + padding: 12px; + font-size: 12px; +} +.side-card h4 { margin: 0 0 4px; font-size: 12px; color: var(--gray-300); display: flex; align-items: center; gap: 8px; } +.side-card p { margin: 0; color: var(--gray-400); line-height: 1.4; } +.side-card .pro-btn { + display: block; + width: 100%; + margin-top: 10px; + padding: 8px 0; + border: 0; + border-radius: 8px; + background: var(--grad); + color: #fff; + font-weight: 600; + font-size: 12px; + text-align: center; +} +.side-card .pro-btn:hover { text-decoration: none; filter: brightness(1.1); } + +.side-footer { + padding: 12px 16px; + border-top: 1px solid var(--gray-800); + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--gray-300); +} +.side-footer .avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, var(--blue-500), var(--purple-600)); + display: flex; + align-items: center; + justify-content: center; +} +.badge-ce { + padding: 2px 7px; + border-radius: 5px; + background: var(--grad); + font-size: 10px; + font-weight: 700; + letter-spacing: .04em; +} + +/* ---------- Main ---------- */ +.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; } + +.topbar { + padding: 22px 32px; + background: rgba(17, 24, 39, .5); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--gray-800); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.topbar h2 { + margin: 0; + font-size: 30px; + font-weight: 700; + background: linear-gradient(90deg, var(--blue-400), var(--purple-400)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.topbar .sub { margin: 4px 0 0; font-size: 13px; color: var(--gray-400); } + +.range { display: flex; gap: 8px; } +.range button { + padding: 8px 16px; + border: 0; + border-radius: 8px; + background: var(--gray-800); + color: var(--gray-400); + font-size: 13px; + font-weight: 500; +} +.range button:hover { background: var(--gray-700); } +.range button.active { + background: var(--grad); + color: #fff; + box-shadow: 0 6px 16px rgba(59, 130, 246, .2); +} + +.content { flex: 1; overflow: auto; padding: 32px; } +.page { display: flex; flex-direction: column; gap: 24px; max-width: 1400px; } + +/* ---------- Bars & banners ---------- */ +.errorbar { + margin: 16px 32px 0; + padding: 10px 16px; + border: 1px solid rgba(248, 113, 113, .3); + background: rgba(127, 29, 29, .2); + border-radius: 8px; + color: var(--red-400); + font-size: 13px; +} +.authbar { + margin: 16px 32px 0; + padding: 12px 16px; + border: 1px solid rgba(251, 191, 36, .35); + background: rgba(120, 82, 6, .15); + border-radius: 8px; + color: var(--amber-400); + font-size: 13px; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.authbar input { flex: 1; min-width: 200px; } + +.banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px; + border-radius: 12px; + border: 1px solid rgba(59, 130, 246, .2); + background: linear-gradient(90deg, rgba(30, 58, 138, .3), rgba(88, 28, 135, .3)); +} +.banner .t { font-size: 14px; font-weight: 500; } +.banner .d { font-size: 12px; color: var(--gray-400); } + +/* ---------- Cards ---------- */ +.grid { display: grid; gap: 24px; } +.grid-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } +.grid-tiles { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } + +.card { + background: var(--gray-900); + border: 1px solid var(--gray-800); + border-radius: 16px; + padding: 20px; +} +.card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; } +.card-title { display: flex; align-items: flex-start; gap: 12px; } +.card-title h3 { margin: 0; font-size: 17px; font-weight: 600; } +.card-title p { margin: 2px 0 0; font-size: 13px; color: var(--gray-400); } +.c-blue { color: var(--blue-400); } +.c-emerald { color: var(--emerald-400); } +.c-purple { color: var(--purple-400); } +.c-amber { color: var(--amber-400); } +.c-green { color: var(--green-400); } +.c-red { color: var(--red-400); } +.muted { color: var(--gray-400); } +.small { font-size: 12px; } +.tiny { font-size: 11px; color: var(--gray-500); } + +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: 8px; + border: 1px solid var(--gray-700); + background: var(--gray-800); + font-size: 13px; + font-weight: 500; + transition: border-color .15s; +} +.btn:hover { border-color: rgba(59, 130, 246, .6); } +.btn-primary { + border: 0; + background: var(--grad); + box-shadow: 0 6px 16px rgba(59, 130, 246, .2); +} +.btn-primary:hover { filter: brightness(1.1); } +.btn-danger:hover { border-color: rgba(248, 113, 113, .6); color: var(--red-400); } + +.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; } +.dot-green { background: var(--green-400); box-shadow: 0 0 0 6px rgba(34, 197, 94, .15); } +.dot-emerald { background: var(--emerald-400); box-shadow: 0 0 0 6px rgba(52, 211, 153, .15); } +.dot-amber { background: var(--amber-400); box-shadow: 0 0 0 6px rgba(251, 191, 36, .14); } +.dot-red { background: var(--red-400); box-shadow: 0 0 0 6px rgba(248, 113, 113, .12); } +.dot-gray { background: var(--gray-500); } +.statusline { display: flex; align-items: center; gap: 10px; font-weight: 500; } + +/* stat tiles */ +.tile { background: var(--gray-900); border: 1px solid var(--gray-800); border-radius: 16px; padding: 18px 20px; } +.tile .k { font-size: 12px; color: var(--gray-400); display: flex; align-items: center; gap: 8px; } +.tile .v { font-size: 26px; font-weight: 700; margin-top: 6px; } +.tile .s { font-size: 11px; color: var(--gray-500); margin-top: 2px; } + +/* tables */ +.tbl { width: 100%; border-collapse: collapse; font-size: 13px; } +.tbl th { + text-align: left; + padding: 8px 10px; + color: var(--gray-400); + font-weight: 500; + font-size: 11px; + text-transform: uppercase; + letter-spacing: .05em; + border-bottom: 1px solid var(--gray-800); +} +.tbl td { padding: 9px 10px; border-bottom: 1px solid rgba(31, 41, 55, .6); } +.tbl tr:last-child td { border-bottom: 0; } +.tbl .num { text-align: right; font-variant-numeric: tabular-nums; } +.tbl-wrap { overflow-x: auto; } + +.chip { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + border: 1px solid var(--gray-700); + background: var(--gray-800); + color: var(--gray-300); +} +.chip-local { border-color: rgba(52, 211, 153, .4); color: var(--emerald-400); background: rgba(52, 211, 153, .08); } +.chip-cloud { border-color: rgba(96, 165, 250, .4); color: var(--blue-400); background: rgba(96, 165, 250, .08); } +.chip-cat { border-color: rgba(192, 132, 252, .4); color: var(--purple-400); background: rgba(192, 132, 252, .08); } + +/* CSS bars (usage / categories) */ +.bars { display: flex; flex-direction: column; gap: 10px; } +.bar-row { display: grid; grid-template-columns: 130px 1fr 60px; gap: 10px; align-items: center; font-size: 12px; } +.bar-row .lbl { color: var(--gray-300); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bar-row .val { text-align: right; color: var(--gray-400); font-variant-numeric: tabular-nums; } +.bar-track { height: 8px; border-radius: 999px; background: var(--gray-800); overflow: hidden; } +.bar-fill { height: 100%; border-radius: 999px; background: var(--grad); } + +/* snippets */ +.snippet-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--gray-800); margin-bottom: 0; } +.snippet-tabs button { + padding: 8px 14px; + border: 0; + background: transparent; + color: var(--gray-400); + font-size: 13px; + border-bottom: 2px solid transparent; +} +.snippet-tabs button.active { color: #fff; border-bottom-color: var(--blue-500); } +pre.snippet { + margin: 0; + padding: 16px; + background: var(--gray-950); + border: 1px solid var(--gray-800); + border-radius: 0 0 12px 12px; + border-top: 0; + font-size: 12.5px; + line-height: 1.6; + overflow-x: auto; + color: var(--gray-300); +} +.endpoint-box { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border: 1px solid var(--gray-700); + border-radius: 10px; + background: var(--gray-950); + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 13px; +} + +/* ---------- Preferences ---------- */ +.prefs-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 24px; align-items: start; } +@media (max-width: 1100px) { .prefs-layout { grid-template-columns: 1fr; } } + +.prio-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; } +.prio { + border: 1px solid var(--gray-700); + border-radius: 12px; + background: var(--gray-900); + padding: 18px 12px; + text-align: center; + transition: border-color .15s, background .15s; +} +.prio:hover { border-color: var(--gray-500); } +.prio.active { border-color: var(--blue-500); background: rgba(37, 99, 235, .08); } +.prio .icon { margin: 0 auto 8px; display: block; width: 24px; height: 24px; } +.prio .n { font-weight: 600; font-size: 14px; } +.prio .d { font-size: 11px; color: var(--gray-400); margin-top: 2px; } + +.field { display: flex; flex-direction: column; gap: 6px; } +.field label { font-size: 12px; color: var(--gray-400); } +.fields-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 16px; } + +.check { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + border: 1px solid var(--gray-700); + border-radius: 10px; + background: var(--gray-900); + cursor: pointer; +} +.check input { width: 16px; height: 16px; accent-color: var(--blue-500); } +.check .n { font-weight: 600; font-size: 14px; } +.check .d { font-size: 12px; color: var(--gray-400); } + +.profile-card { + background: linear-gradient(160deg, rgba(30, 58, 138, .25), rgba(88, 28, 135, .2)); + border: 1px solid rgba(96, 165, 250, .25); + border-radius: 16px; + padding: 20px; +} +.profile-card h3 { margin: 0 0 14px; font-size: 16px; display: flex; align-items: center; gap: 10px; } +.kv { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; } +.kv .k { color: var(--gray-300); } +.kv .v { font-weight: 600; } + +.guide-item { border-radius: 10px; border: 1px solid var(--gray-700); padding: 12px; margin-top: 10px; background: rgba(31, 41, 55, .4); } +.guide-item .n { font-size: 13px; font-weight: 600; display: flex; align-items: center; gap: 8px; } +.guide-item .d { font-size: 11.5px; color: var(--gray-400); margin-top: 3px; } + +/* ---------- Playground ---------- */ +.play-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px; align-items: start; } +@media (max-width: 1100px) { .play-grid { grid-template-columns: 1fr; } } +textarea.prompt { width: 100%; min-height: 120px; resize: vertical; line-height: 1.5; } +.answer { + white-space: pre-wrap; + word-break: break-word; + font-size: 14px; + line-height: 1.6; + color: var(--gray-300); + max-height: 420px; + overflow: auto; +} +.meter { height: 8px; border-radius: 999px; background: var(--gray-800); overflow: hidden; margin-top: 6px; } +.meter div { height: 100%; background: linear-gradient(90deg, var(--emerald-400), var(--amber-400), var(--red-400)); border-radius: 999px; } +.meta-chips { display: flex; flex-wrap: wrap; gap: 8px; } + +/* toast */ +#toast { + position: fixed; + bottom: 24px; + right: 24px; + padding: 10px 18px; + border-radius: 10px; + background: var(--gray-800); + border: 1px solid rgba(52, 211, 153, .5); + color: var(--emerald-400); + font-size: 13px; + box-shadow: 0 10px 30px rgba(0, 0, 0, .5); + opacity: 0; + transform: translateY(8px); + transition: opacity .2s, transform .2s; + pointer-events: none; + z-index: 100; +} +#toast.show { opacity: 1; transform: none; } + +@media (max-width: 900px) { + .app { flex-direction: column; } + .sidebar { width: 100%; flex-direction: row; align-items: center; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--gray-800); } + .brand { border-bottom: 0; padding: 12px 16px; } + .nav { flex-direction: row; padding: 8px; } + .nav button { width: auto; white-space: nowrap; } + .nav button .chev, .side-card, .side-footer { display: none; } + .content { padding: 16px; } + .topbar { padding: 16px; flex-wrap: wrap; } +} diff --git a/internal/webui/webui.go b/internal/webui/webui.go new file mode 100644 index 0000000..1394e43 --- /dev/null +++ b/internal/webui/webui.go @@ -0,0 +1,27 @@ +// Package webui embeds the optional single-page management console served +// by the gateway at "/". The UI is plain HTML/CSS/JS with zero build step +// and zero runtime dependencies, so the single-binary story is unchanged: +// everything ships inside the executable via go:embed. The gateway remains +// fully usable without it (CLI + HTTP API); set server.ui: false to turn +// the console off entirely. +package webui + +import ( + "embed" + "io/fs" + "net/http" +) + +//go:embed static +var static embed.FS + +// Handler serves the embedded console assets. index.html is served at "/" +// by the standard file-server index behavior. +func Handler() http.Handler { + sub, err := fs.Sub(static, "static") + if err != nil { + // Unreachable: "static" is embedded above at compile time. + panic("webui: embedded assets missing: " + err.Error()) + } + return http.FileServerFS(sub) +}