From 4f01cc8e6060bdc960277ce4daf7e09dd587d151 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Tue, 21 Jul 2026 12:56:16 +0200 Subject: [PATCH 1/5] fix(dashboard): polish demo and provider management --- CLAUDE.md | 2 +- docs/advanced/configuration.mdx | 13 +++ internal/admin/dashboard/dashboard_test.go | 17 ++++ .../admin/dashboard/static/css/dashboard.css | 96 +++++++++++++++++-- .../admin/dashboard/static/js/dashboard.js | 32 ++++++- .../js/modules/dashboard-display.test.cjs | 23 +++++ .../js/modules/dashboard-layout.test.cjs | 41 +++++++- .../js/modules/providers-config.test.cjs | 20 ++-- .../dashboard/static/js/modules/providers.js | 10 +- .../static/js/modules/providers.test.cjs | 12 ++- .../admin/dashboard/templates/layout.html | 7 +- .../dashboard/templates/page-audit-logs.html | 12 ++- .../dashboard/templates/page-overview.html | 30 +++--- .../templates/page-providers-config.html | 6 +- .../admin/dashboard/templates/page-usage.html | 6 +- .../admin/handler_provider_credentials.go | 66 +++++++------ .../handler_provider_credentials_test.go | 27 +++++- internal/providers/registry.go | 12 +-- internal/providers/registry_init.go | 17 ++++ .../providers/registry_provider_refresh.go | 1 + .../providers/registry_unregister_test.go | 70 ++++++++++++-- 21 files changed, 420 insertions(+), 100 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 53caf4f3f..a3ed1f957 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,6 +134,6 @@ Full reference: `.env.template` and `config/config.yaml` - **Metrics:** `METRICS_ENABLED` (false), `METRICS_ENDPOINT` (/metrics) - **Guardrails:** Definitions are persisted in the `guardrail_definitions` store and managed via the admin API/dashboard; `config/config.yaml` entries are validated and upserted into that store at startup (a seed, not the source of truth). `GUARDRAILS_ENABLED` env var gates the feature. - **Provider API key rotation:** Any API-key provider accepts several keys: `[_SUFFIX]_API_KEY_` env vars (numbered from 2; `_1` is accepted as a synonym for the unsuffixed key) or `providers..api_keys` in `config.yaml` (merged after `api_key`, de-duplicated, unresolved `${...}` entries dropped; env replaces the whole YAML list). Two or more keys turn on round-robin rotation, drawn per outbound HTTP request — including retries, so a 429'd request retries under the next key. Realtime websocket sessions pick a key per session. Counters are in-memory per instance. The trailing number names a key, not a provider: `OPENAI_API_KEY_2` is key 2 of `openai`, while `OPENAI_REGION_2_API_KEY` is the sole key of provider `openai-region-2`. **Rotation defeats provider prompt caching** (providers scope the cache to the key that filled it); use it to lift per-key rate limits, not to save cost. Keyless (Ollama, vLLM) and non-API-key providers (Vertex, Bedrock) are unaffected. -- **Provider credentials without env vars:** Every provider below can instead be configured from the admin dashboard's Providers page (or `/admin/provider-credentials` GET/PUT/DELETE), persisted to the `provider_credentials` store — the same declarative-shadows-store precedence as MCP servers: a provider name declared via env vars/`config.yaml` is read-only in the dashboard (`managed: true`), and a store row upsert/delete hot-registers or unregisters the provider into the live registry immediately, no restart. `GOMODEL` boots fine with zero providers configured (empty catalog) so this is a complete alternative to env-var credentials, not just a supplement. API keys (`api_keys`, an ordered rotation list, same semantics as `providers..api_keys`) and service-account secrets are redacted as `***` on read; an upsert echoing `***` at a position preserves the stored value there (rejected if that position was never set). Disabling a row (`enabled: false`) unregisters it from routing without deleting the stored credentials. +- **Provider credentials without env vars:** Every provider below can instead be configured from the admin dashboard's Providers page (or `/admin/provider-credentials` GET/PUT/DELETE), persisted to the `provider_credentials` store — the same declarative-shadows-store precedence as MCP servers: a provider name declared via env vars/`config.yaml` is read-only in the dashboard (`managed: true`), and a store row upsert/delete hot-registers or unregisters the provider into the live registry immediately, no restart. `GOMODEL` boots fine with zero providers configured (empty catalog) so this is a complete alternative to env-var credentials, not just a supplement. API keys (`api_keys`, an ordered rotation list, same semantics as `providers..api_keys`) and service-account secrets are redacted as `***********` on read; an upsert echoing any all-asterisk mask of at least three characters at a position preserves the stored value there (rejected if that position was never set). Disabling a row (`enabled: false`) unregisters it from routing without deleting the stored credentials. - **Providers:** `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `ANTHROPIC_DEFAULT_MAX_TOKENS` (optional default `max_tokens` for Anthropic-translated requests that omit it; default 4096), `GEMINI_API_KEY`, `USE_GOOGLE_GEMINI_NATIVE_API` (true by default; false uses Gemini's OpenAI-compatible chat API), `XAI_API_KEY`, `GROQ_API_KEY`, `FIREWORKS_API_KEY`, `FIREWORKS_BASE_URL` (optional Fireworks AI endpoint override; default `https://api.fireworks.ai/inference/v1`), `META_API_KEY`, `META_BASE_URL` (optional Meta Model API endpoint override; default `https://api.meta.ai/v1`; Muse Spark models, e.g. `muse-spark-1.1`), `OPENROUTER_API_KEY`, `OPENROUTER_SITE_URL`/`OPENROUTER_APP_NAME` (optional OpenRouter attribution headers), `ZAI_API_KEY`, `ZAI_BASE_URL` (optional Z.ai endpoint override), `MINIMAX_API_KEY`, `MINIMAX_BASE_URL` (optional MiniMax endpoint override), `XIAOMI_API_KEY`, `XIAOMI_BASE_URL` (optional Xiaomi MiMo endpoint override), `OPENCODE_GO_API_KEY`, `OPENCODE_GO_BASE_URL` (optional OpenCode Go/Zen endpoint override; default `https://opencode.ai/zen/go/v1`), `OPENCODE_GO_MESSAGES_MODELS` (optional comma-separated model IDs routed to the Anthropic-native `/messages` endpoint instead of `/chat/completions`; default `qwen3.7-max`), `BAILIAN_API_KEY`, `BAILIAN_BASE_URL` (optional Bailian base URL for region switching; default `https://dashscope.aliyuncs.com/compatible-mode/v1`), `AZURE_API_KEY`, `AZURE_BASE_URL` (Azure OpenAI deployment base URL), `AZURE_API_VERSION` (optional Azure API version), `ORACLE_API_KEY` (Oracle API key), `ORACLE_BASE_URL` (Oracle OpenAI-compatible base URL), `BEDROCK_BASE_URL` (Bedrock Runtime region or endpoint), `BEDROCK_MANTLE_API_KEY`, `BEDROCK_MANTLE_BASE_URL` (Mantle region or endpoint), `BEDROCK_MANTLE_API_MODE` (`auto`, `openai`, or `standard`), `[_SUFFIX]_MODELS` (comma-separated configured model list for any provider type), `OLLAMA_BASE_URL`, `VLLM_BASE_URL`, `VLLM_API_KEY` (optional upstream vLLM bearer token) - **Provider model metadata:** `providers..models` accepts either model IDs (strings) or `{id, metadata}` objects. When `metadata` is supplied (`display_name`, `context_window`, `max_output_tokens`, `modes`, `capabilities`, `pricing`, …) it is merged onto the remote ai-model-list entry during enrichment, with operator values winning per-field. Primary use case: advertising context windows, capabilities, and pricing for local models (Ollama) and other custom endpoints whose IDs are not in the upstream registry. diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 176d8abdb..154f6c0aa 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -53,6 +53,13 @@ warning at the top of the dashboard, and exposes `DEMO_MODE=on` through the allowlisted `/admin/runtime/config` response. Demo mode does not reset storage; schedule that separately in the deployment. +Demo mode also does not bypass authentication. A startup log mode of +`managed_keys` means the configured storage still contains at least one managed +gateway API key, so requests require a bearer token even when +`GOMODEL_MASTER_KEY` is empty. Disabled or expired managed keys keep +authentication enabled as a fail-closed safety measure. Delete every managed +key to return to unauthenticated mode, or configure a master key for recovery. + #### MCP Gateway See [MCP Gateway](/features/mcp-gateway) for the full feature guide. @@ -124,6 +131,12 @@ Storage is shared by audit logging, usage tracking, and future features like IAM | `LOGGING_FLUSH_INTERVAL` | Flush interval in seconds | `5` | | `LOGGING_RETENTION_DAYS` | Auto-delete after N days (0 = forever) | `30` | +Realtime dashboard previews and persisted audit logs are separate features. +With `DASHBOARD_LIVE_LOGS_ENABLED=true` and `LOGGING_ENABLED=false`, requests +can appear live but are not written to storage and disappear after a page +refresh. When audit logging is enabled, writes are asynchronous and can take up +to `LOGGING_FLUSH_INTERVAL` seconds to appear through the stored-log API. + When `LOGGING_LOG_BODIES` is enabled, request and response bodies are stored in full. These may contain sensitive data such as PII or API keys embedded in diff --git a/internal/admin/dashboard/dashboard_test.go b/internal/admin/dashboard/dashboard_test.go index e768dae82..ee44d107f 100644 --- a/internal/admin/dashboard/dashboard_test.go +++ b/internal/admin/dashboard/dashboard_test.go @@ -99,6 +99,23 @@ func TestIndex_DemoModeShowsWarning(t *testing.T) { if !strings.Contains(body, "Do not enter sensitive or personal data. Demo data is reset regularly.") { t.Error("expected demo mode data warning in page HTML") } + links := []struct { + label string + href string + text string + }{ + {label: "website", href: "https://gomodel.enterpilot.io/", text: "gomodel.enterpilot.io"}, + {label: "docs", href: "https://gomodel.enterpilot.io/docs", text: "Docs"}, + {label: "GitHub", href: "https://github.com/ENTERPILOT/GoModel", text: "GitHub"}, + } + for _, link := range links { + if !strings.Contains(body, `href="`+link.href+`" target="_blank" rel="noopener noreferrer">`+link.text+``) { + t.Errorf("expected demo mode %s link in page HTML", link.label) + } + } + if got := strings.Count(body, `target="_blank" rel="noopener noreferrer"`); got < len(links) { + t.Errorf("demo mode external links with safe target attributes = %d, want at least %d", got, len(links)) + } } func TestIndex_StandardModeHidesDemoWarning(t *testing.T) { diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index b14137cb8..d0f2f4531 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -668,10 +668,8 @@ body.dashboard-modal-open { } .demo-mode-banner { - position: sticky; - top: 16px; - z-index: 8; - display: flex; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; margin-bottom: 24px; @@ -690,7 +688,7 @@ body.dashboard-modal-open { color: var(--warning); } -.demo-mode-banner div { +.demo-mode-banner-copy { display: flex; align-items: baseline; gap: 8px; @@ -706,6 +704,39 @@ body.dashboard-modal-open { text-transform: uppercase; } +.demo-mode-banner-links { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; +} + +.demo-mode-banner-links a { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 4px 8px; + border: 1px solid color-mix(in srgb, var(--warning) 42%, var(--border)); + border-radius: var(--radius); + color: var(--text); + font-size: 12px; + font-weight: 600; + line-height: 1; + text-decoration: none; + white-space: nowrap; +} + +.demo-mode-banner-links a:hover { + border-color: var(--warning); + background: color-mix(in srgb, var(--warning) 16%, transparent); + color: var(--text); +} + +.demo-mode-banner-links a:focus-visible { + outline: 2px solid color-mix(in srgb, var(--warning) 42%, transparent); + outline-offset: 2px; +} + .page-header { display: flex; align-items: center; @@ -975,6 +1006,29 @@ body.dashboard-modal-open { gap: 12px; } +.page-with-sticky-date { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + column-gap: 12px; +} + +.page-with-sticky-date > * { + grid-column: 1 / -1; +} + +.page-with-sticky-date > .date-range-page-header { + grid-column: 1; +} + +.page-with-sticky-date > .sticky-date-range { + position: sticky; + top: 16px; + z-index: 8; + grid-column: 2; + justify-self: end; +} + /* Interval Picker */ .interval-picker { display: inline-flex; @@ -1078,6 +1132,10 @@ body.dashboard-modal-open { grid-column: span 2; } +.provider-status-overview-card { + grid-column: span 1; +} + .provider-status-flag.is-healthy { border-color: color-mix(in srgb, var(--success) 45%, var(--border)); background: color-mix(in srgb, var(--success) 10%, var(--bg-surface)); @@ -5413,13 +5471,18 @@ body.conversation-drawer-open { padding: 20px; } .demo-mode-banner { - top: 10px; align-items: flex-start; + grid-template-columns: auto minmax(0, 1fr); } - .demo-mode-banner div { + .demo-mode-banner-copy { display: grid; gap: 2px; } + .demo-mode-banner-links { + grid-column: 2; + justify-content: flex-start; + flex-wrap: wrap; + } .auth-dialog-shell { align-items: end; padding: 12px; @@ -5475,6 +5538,25 @@ body.conversation-drawer-open { justify-content: space-between; flex-wrap: wrap; } + .page-with-sticky-date { + grid-template-columns: minmax(0, 1fr); + } + .page-with-sticky-date > .date-range-page-header, + .page-with-sticky-date > .sticky-date-range { + grid-column: 1; + } + .page-with-sticky-date > .date-range-page-header { + margin-bottom: 12px; + } + .page-with-sticky-date > .sticky-date-range { + top: 10px; + width: 100%; + margin-bottom: 24px; + } + .sticky-date-range .date-picker-trigger { + width: 100%; + justify-content: space-between; + } .settings-panel-title-row { gap: 8px; diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index 9ea38b5b6..6e99849b7 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -1122,9 +1122,35 @@ function dashboard() { }, formatTokensShort(n) { - if (n >= 1000000) return (n / 1000000).toFixed(1) + "M"; - if (n >= 1000) return (n / 1000).toFixed(1) + "K"; - return String(n); + if (n == null || n === "") return "-"; + const value = Number(n); + if (!Number.isFinite(value)) return "-"; + const absolute = Math.abs(value); + const units = [ + { threshold: 1000000000, suffix: "B" }, + { threshold: 1000000, suffix: "M" }, + { threshold: 1000, suffix: "K" }, + ]; + for (let index = 0; index < units.length; index += 1) { + let unit = units[index]; + if (absolute >= unit.threshold) { + let compact = value / unit.threshold; + if (Math.abs(Number(compact.toFixed(1))) >= 1000 && index > 0) { + unit = units[index - 1]; + compact = value / unit.threshold; + } + return ( + compact.toFixed(1).replace(/\.0$/, "") + unit.suffix + ); + } + } + return String(value); + }, + + tokenCountTitle(label, n) { + const value = n == null || n === "" ? NaN : Number(n); + const exact = Number.isFinite(value) ? this.formatNumber(value) : "-"; + return String(label || "Tokens") + ": " + exact; }, formatTimestamp(ts) { diff --git a/internal/admin/dashboard/static/js/modules/dashboard-display.test.cjs b/internal/admin/dashboard/static/js/modules/dashboard-display.test.cjs index a6feec6ab..86cab233c 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-display.test.cjs +++ b/internal/admin/dashboard/static/js/modules/dashboard-display.test.cjs @@ -165,6 +165,29 @@ test('formatCost uses data placeholder for missing values', () => { assert.equal(app.formatCost('0.25'), '$0.25'); }); +test('formatTokensShort uses K, M, and B suffixes for large token counts', () => { + const app = loadDashboardApp(); + + assert.equal(app.formatTokensShort(999), '999'); + assert.equal(app.formatTokensShort(1000), '1K'); + assert.equal(app.formatTokensShort(1250), '1.3K'); + assert.equal(app.formatTokensShort(999950), '1M'); + assert.equal(app.formatTokensShort(56672513), '56.7M'); + assert.equal(app.formatTokensShort(12072437), '12.1M'); + assert.equal(app.formatTokensShort(999950000), '1B'); + assert.equal(app.formatTokensShort(2500000000), '2.5B'); + assert.equal(app.formatTokensShort(null), '-'); + assert.equal(app.formatTokensShort('not-a-number'), '-'); +}); + +test('tokenCountTitle exposes the exact localized count and token type', () => { + const app = loadDashboardApp(); + + assert.equal(app.tokenCountTitle('Input tokens', 56672513), 'Input tokens: ' + (56672513).toLocaleString()); + assert.equal(app.tokenCountTitle('Output tokens', 12072437), 'Output tokens: ' + (12072437).toLocaleString()); + assert.equal(app.tokenCountTitle('Total tokens', null), 'Total tokens: -'); +}); + test('system theme media changes rerender all dashboard charts', () => { let mediaChangeHandler = null; const app = loadDashboardApp({ diff --git a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs index 3df4d8f89..892af9346 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs +++ b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs @@ -94,6 +94,36 @@ test("sidebar and main content share the flex layout without manual content offs assert.match(sidebarLogoRule, /color:\s*var\(--accent\)/); }); +test("demo notice scrolls normally while page date ranges stay sticky", () => { + const layout = readFixture("../../../templates/layout.html"); + const overview = readFixture("../../../templates/page-overview.html"); + const usage = readFixture("../../../templates/page-usage.html"); + const audit = readFixture("../../../templates/page-audit-logs.html"); + const css = readFixture("../../css/dashboard.css"); + + assert.match(layout, /