From 133d824a660b13f666b4c4383b3e0d177d99c27d Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Fri, 3 Jul 2026 19:02:37 +0200 Subject: [PATCH 1/3] feat(dashboard): label analytics and page-level filters on usage page - Usage by Label breakdown (chart/table toggle) with one deterministic color per label, shared between chart bars and label chips - GET /admin/usage/labels aggregation implemented in all three storage backends (SQLite json_each, PostgreSQL jsonb_array_elements_text, MongoDB $unwind); label filter on the request log - Request log renders clickable label chips; clicking one filters the whole page, clicking again clears it - Filters (model, provider, label, user path) moved out of the request log into a page-level bar that drives every widget; search and the hide-cached toggle stay log-scoped - Model/provider/label filters centralized in UsageQueryParams and the shared per-backend condition builders, deduplicating the log readers and all three pricing recalculators; every usage aggregate endpoint now accepts them - Total Requests and Estimated Cost stat cards for the selected period and filters, with provider/cache and input/output tooltips - Standard inline help on the label section ("One request can have multiple labels...") - Demo seeder labels roughly two thirds of generated traffic Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- cmd/gomodel/docs/docs.go | 375 +++++++++++++- docs/openapi.json | 466 +++++++++++++++++- .../admin/dashboard/static/css/dashboard.css | 75 ++- .../admin/dashboard/static/js/dashboard.js | 16 +- .../dashboard/static/js/modules/charts.js | 69 +++ .../static/js/modules/charts.test.cjs | 64 +++ .../js/modules/dashboard-display.test.cjs | 7 + .../js/modules/dashboard-layout.test.cjs | 26 +- .../dashboard/static/js/modules/live-logs.js | 3 +- .../static/js/modules/live-logs.test.cjs | 7 +- .../dashboard/static/js/modules/usage.js | 189 ++++++- .../static/js/modules/usage.test.cjs | 125 ++++- .../admin/dashboard/templates/page-usage.html | 130 ++++- internal/admin/handler.go | 3 + internal/admin/handler_test.go | 80 ++- internal/admin/handler_usage.go | 55 ++- internal/admin/routes.go | 1 + internal/admin/routes_test.go | 1 + internal/usage/labels_sqlite_test.go | 147 ++++++ internal/usage/reader.go | 31 +- internal/usage/reader_mongodb.go | 148 ++++-- internal/usage/reader_mongodb_test.go | 46 ++ internal/usage/reader_postgresql.go | 86 +++- internal/usage/reader_sqlite.go | 77 ++- internal/usage/recalculate_pricing.go | 6 +- internal/usage/recalculate_pricing_mongodb.go | 15 +- .../usage/recalculate_pricing_mongodb_test.go | 6 +- .../usage/recalculate_pricing_postgresql.go | 11 +- internal/usage/recalculate_pricing_sqlite.go | 8 - .../usage/recalculate_pricing_sqlite_test.go | 6 +- tools/seed-demo-data.sh | 18 +- 32 files changed, 2087 insertions(+), 212 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 226ed933..be6362eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,7 +118,7 @@ Full reference: `.env.template` and `config/config.yaml` - **Storage:** `STORAGE_TYPE` (sqlite), `SQLITE_PATH` (data/gomodel.db), `POSTGRES_URL`, `MONGODB_URL` - **Models:** `MODELS_ENABLED_BY_DEFAULT` (true), `KEEP_ONLY_ALIASES_AT_MODELS_ENDPOINT` (false), `CONFIGURED_PROVIDER_MODELS_MODE` (`fallback` or `allowlist`, default `fallback`; `allowlist` skips upstream `/models` for providers with configured lists); persisted overrides restrict/allow selectors with `user_paths`. When alias-only models listing is enabled, `GET /v1/models` returns only model aliases, not full concrete model specs, to operators. - **Virtual models:** Redirects (aliases / load balancers) and access policies are managed in the admin dashboard and persisted to the `virtual_models` store. A redirect with one target is a plain alias; a redirect with several targets is load balanced by `strategy`: `round_robin` (default; rotates across targets, honoring per-target `weight`) or `cost` (always routes to the cheapest catalog-priced available target, falling back to the first target when none are priced). Unavailable targets are skipped, so a redirect works while any target is live. Virtual models can also be declared as infrastructure-as-code under `virtual_models:` in `config.yaml` or via the `VIRTUAL_MODELS` env var (a JSON array; env merges over YAML, winning per `source`). Declarative entries are validated at startup, override admin-store rows with the same `source`, and are read-only in the dashboard. -- **Tagging:** Every request can be labelled from configured HTTP headers. Rules are managed in the dashboard (Settings → "Tagging based on headers", persisted to the `tagging_settings` store) or declared as infrastructure-as-code under `tagging.headers:` in `config.yaml` / numbered env vars `TAGGING_HEADER_1=X-My-Tags` with optional `TAGGING_HEADER_1_PREFIX` (trimmed from each extracted label only), `TAGGING_HEADER_1_DONOTPASS` (default false: headers are forwarded as-is; true strips the header before provider forwarding on passthrough/realtime routes — translated routes never forward client headers), and `TAGGING_HEADER_1_DELIMITER` (default `,`; one header value can carry several labels). An env entry replaces the whole YAML entry with the same header name (unset companion vars reset fields to defaults rather than inheriting YAML values); declarative entries override admin-store rows and are read-only in the dashboard. Credential-bearing headers (`Authorization`, `Cookie`, API-key headers, …) are rejected as tagging sources. Labels are recorded on usage entries (`labels`) and audit log entries (`data.labels`). +- **Tagging:** Every request can be labelled from configured HTTP headers. Rules are managed in the dashboard (Settings → "Tagging based on headers", persisted to the `tagging_settings` store) or declared as infrastructure-as-code under `tagging.headers:` in `config.yaml` / numbered env vars `TAGGING_HEADER_1=X-My-Tags` with optional `TAGGING_HEADER_1_PREFIX` (trimmed from each extracted label only), `TAGGING_HEADER_1_DONOTPASS` (default false: headers are forwarded as-is; true strips the header before provider forwarding on passthrough/realtime routes — translated routes never forward client headers), and `TAGGING_HEADER_1_DELIMITER` (default `,`; one header value can carry several labels). An env entry replaces the whole YAML entry with the same header name (unset companion vars reset fields to defaults rather than inheriting YAML values); declarative entries override admin-store rows and are read-only in the dashboard. Credential-bearing headers (`Authorization`, `Cookie`, API-key headers, …) are rejected as tagging sources. Labels are recorded on usage entries (`labels`) and audit log entries (`data.labels`). The dashboard usage page shows a by-label breakdown (`GET /admin/usage/labels`) and label chips with a label filter on the request log (`label` query param on `GET /admin/usage/log`). - **Audit logging:** `LOGGING_ENABLED` (false), `LOGGING_LOG_BODIES` (false), `LOGGING_LOG_AUDIO_BODIES` (false: refines `LOGGING_LOG_BODIES` for audio endpoints — base64 audio for both `/v1/audio/speech` output and `/v1/audio/transcriptions` upload (≤8 MB each, else `too_large`) + dashboard playback, plus transcription upload metadata; no effect unless `LOGGING_LOG_BODIES` is on, in which case audio-off records a placeholder), `LOGGING_LOG_HEADERS` (false), `LOGGING_RETENTION_DAYS` (30) - **Usage tracking:** `USAGE_ENABLED` (true), `ENFORCE_RETURNING_USAGE_DATA` (true), `USAGE_RETENTION_DAYS` (90) - **Dashboard live logs:** diff --git a/cmd/gomodel/docs/docs.go b/cmd/gomodel/docs/docs.go index f0b1ba67..590a2e85 100644 --- a/cmd/gomodel/docs/docs.go +++ b/cmd/gomodel/docs/docs.go @@ -627,6 +627,24 @@ const docTemplate = `{ "name": "interval", "in": "query" }, + { + "type": "string", + "description": "Filter by exact model name", + "name": "model", + "in": "query" + }, + { + "type": "string", + "description": "Filter by provider name or provider type", + "name": "provider", + "in": "query" + }, + { + "type": "string", + "description": "Filter by request label (exact match)", + "name": "label", + "in": "query" + }, { "type": "string", "description": "Filter by tracked user path subtree", @@ -1218,6 +1236,96 @@ const docTemplate = `{ ] } }, + "/admin/tagging/settings": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Get header tagging rules", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.taggingSettingsResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Replace operator header tagging rules", + "parameters": [ + { + "description": "Operator tagging rules", + "name": "settings", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/admin.updateTaggingSettingsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/admin.taggingSettingsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, "/admin/usage/daily": { "get": { "produces": [ @@ -1252,6 +1360,24 @@ const docTemplate = `{ "name": "interval", "in": "query" }, + { + "type": "string", + "description": "Filter by exact model name", + "name": "model", + "in": "query" + }, + { + "type": "string", + "description": "Filter by provider name or provider type", + "name": "provider", + "in": "query" + }, + { + "type": "string", + "description": "Filter by request label (exact match)", + "name": "label", + "in": "query" + }, { "type": "string", "description": "Filter by tracked user path subtree", @@ -1295,6 +1421,96 @@ const docTemplate = `{ ] } }, + "/admin/usage/labels": { + "get": { + "description": "Returns per-label token and cost aggregates. Requests carrying\nseveral labels count once per label, so rows overlap and do\nnot sum to the period totals. Unlabelled requests are omitted.", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Get usage breakdown by request label", + "parameters": [ + { + "type": "integer", + "description": "Number of days (default 30)", + "name": "days", + "in": "query" + }, + { + "type": "string", + "description": "Start date (YYYY-MM-DD)", + "name": "start_date", + "in": "query" + }, + { + "type": "string", + "description": "End date (YYYY-MM-DD)", + "name": "end_date", + "in": "query" + }, + { + "type": "string", + "description": "Filter by exact model name", + "name": "model", + "in": "query" + }, + { + "type": "string", + "description": "Filter by provider name or provider type", + "name": "provider", + "in": "query" + }, + { + "type": "string", + "description": "Filter by request label (exact match)", + "name": "label", + "in": "query" + }, + { + "type": "string", + "description": "Filter by tracked user path subtree", + "name": "user_path", + "in": "query" + }, + { + "type": "string", + "description": "Cache mode filter: uncached, cached, all (default uncached)", + "name": "cache_mode", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/usage.LabelUsage" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/core.GatewayError" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + } + }, "/admin/usage/log": { "get": { "produces": [ @@ -1325,7 +1541,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "Filter by model name", + "description": "Filter by exact model name", "name": "model", "in": "query" }, @@ -1335,6 +1551,12 @@ const docTemplate = `{ "name": "provider", "in": "query" }, + { + "type": "string", + "description": "Filter by request label (exact match)", + "name": "label", + "in": "query" + }, { "type": "string", "description": "Filter by tracked user path subtree", @@ -1421,6 +1643,24 @@ const docTemplate = `{ "name": "end_date", "in": "query" }, + { + "type": "string", + "description": "Filter by exact model name", + "name": "model", + "in": "query" + }, + { + "type": "string", + "description": "Filter by provider name or provider type", + "name": "provider", + "in": "query" + }, + { + "type": "string", + "description": "Filter by request label (exact match)", + "name": "label", + "in": "query" + }, { "type": "string", "description": "Filter by tracked user path subtree", @@ -1554,6 +1794,24 @@ const docTemplate = `{ "name": "end_date", "in": "query" }, + { + "type": "string", + "description": "Filter by exact model name", + "name": "model", + "in": "query" + }, + { + "type": "string", + "description": "Filter by provider name or provider type", + "name": "provider", + "in": "query" + }, + { + "type": "string", + "description": "Filter by request label (exact match)", + "name": "label", + "in": "query" + }, { "type": "string", "description": "Filter by tracked user path subtree", @@ -1668,6 +1926,24 @@ const docTemplate = `{ "name": "end_date", "in": "query" }, + { + "type": "string", + "description": "Filter by exact model name", + "name": "model", + "in": "query" + }, + { + "type": "string", + "description": "Filter by provider name or provider type", + "name": "provider", + "in": "query" + }, + { + "type": "string", + "description": "Filter by request label (exact match)", + "name": "label", + "in": "query" + }, { "type": "string", "description": "Filter by tracked user path subtree", @@ -4750,6 +5026,22 @@ const docTemplate = `{ } } }, + "admin.taggingSettingsResponse": { + "type": "object", + "properties": { + "editable": { + "description": "Editable reports whether operator rules can be saved (false when no\nsettings storage is available).", + "type": "boolean" + }, + "headers": { + "description": "Headers is the effective rule set: config/env-managed rules (read-only,\nmanaged=true) followed by operator rules persisted in the admin store.", + "type": "array", + "items": { + "$ref": "#/definitions/tagging.Rule" + } + } + } + }, "admin.updateBudgetSettingsRequest": { "type": "object", "properties": { @@ -4779,6 +5071,17 @@ const docTemplate = `{ } } }, + "admin.updateTaggingSettingsRequest": { + "type": "object", + "properties": { + "headers": { + "type": "array", + "items": { + "$ref": "#/definitions/tagging.Rule" + } + } + } + }, "admin.upsertBudgetRequest": { "type": "object", "properties": { @@ -5213,6 +5516,13 @@ const docTemplate = `{ } ] }, + "labels": { + "description": "Labels are request labels extracted from configured tagging headers.", + "type": "array", + "items": { + "type": "string" + } + }, "max_tokens": { "type": "integer" }, @@ -7097,6 +7407,31 @@ const docTemplate = `{ } } }, + "tagging.Rule": { + "type": "object", + "properties": { + "delimiter": { + "description": "Delimiter splits one header value into multiple labels. Default: \",\".", + "type": "string" + }, + "do_not_pass": { + "description": "DoNotPass strips the header before forwarding the request upstream.\nDefault: false (headers are passed through as-is).", + "type": "boolean" + }, + "header": { + "description": "Header is the canonical HTTP header name to read labels from.", + "type": "string" + }, + "managed": { + "description": "Managed marks a rule declared in config/env; such rules are read-only in\nthe dashboard. Never persisted.", + "type": "boolean" + }, + "prefix": { + "description": "Prefix is optionally trimmed from the front of each label. Trimming only\naffects the extracted label, never the forwarded header value.", + "type": "string" + } + } + }, "usage.CacheOverview": { "type": "object", "properties": { @@ -7205,6 +7540,38 @@ const docTemplate = `{ } } }, + "usage.LabelUsage": { + "type": "object", + "properties": { + "input_cost": { + "type": "number", + "x-nullable": true + }, + "input_tokens": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "output_cost": { + "type": "number", + "x-nullable": true + }, + "output_tokens": { + "type": "integer" + }, + "requests": { + "type": "integer" + }, + "total_cost": { + "type": "number", + "x-nullable": true + }, + "total_tokens": { + "type": "integer" + } + } + }, "usage.ModelUsage": { "type": "object", "properties": { @@ -7356,6 +7723,12 @@ const docTemplate = `{ "input_tokens": { "type": "integer" }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, "model": { "type": "string" }, diff --git a/docs/openapi.json b/docs/openapi.json index b9a20ac8..03edf11e 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -810,6 +810,30 @@ "type": "string" } }, + { + "description": "Filter by exact model name", + "name": "model", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by provider name or provider type", + "name": "provider", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by request label (exact match)", + "name": "label", + "in": "query", + "schema": { + "type": "string" + } + }, { "description": "Filter by tracked user path subtree", "name": "user_path", @@ -1607,6 +1631,125 @@ } } }, + "/admin/tagging/settings": { + "get": { + "tags": [ + "admin" + ], + "summary": "Get header tagging rules", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.taggingSettingsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "x-mint": { + "metadata": { + "sidebarTitle": "/admin/tagging/settings" + } + } + }, + "put": { + "tags": [ + "admin" + ], + "summary": "Replace operator header tagging rules", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.updateTaggingSettingsRequest" + } + } + }, + "description": "Operator tagging rules", + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/admin.taggingSettingsResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "x-mint": { + "metadata": { + "sidebarTitle": "/admin/tagging/settings" + } + } + } + }, "/admin/usage/daily": { "get": { "tags": [ @@ -1646,6 +1789,30 @@ "type": "string" } }, + { + "description": "Filter by exact model name", + "name": "model", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by provider name or provider type", + "name": "provider", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by request label (exact match)", + "name": "label", + "in": "query", + "schema": { + "type": "string" + } + }, { "description": "Filter by tracked user path subtree", "name": "user_path", @@ -1710,6 +1877,126 @@ } } }, + "/admin/usage/labels": { + "get": { + "description": "Returns per-label token and cost aggregates. Requests carrying\nseveral labels count once per label, so rows overlap and do\nnot sum to the period totals. Unlabelled requests are omitted.", + "tags": [ + "admin" + ], + "summary": "Get usage breakdown by request label", + "parameters": [ + { + "description": "Number of days (default 30)", + "name": "days", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "description": "Start date (YYYY-MM-DD)", + "name": "start_date", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "End date (YYYY-MM-DD)", + "name": "end_date", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by exact model name", + "name": "model", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by provider name or provider type", + "name": "provider", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by request label (exact match)", + "name": "label", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by tracked user path subtree", + "name": "user_path", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Cache mode filter: uncached, cached, all (default uncached)", + "name": "cache_mode", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/usage.LabelUsage" + } + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/core.GatewayError" + } + } + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "x-mint": { + "metadata": { + "sidebarTitle": "/admin/usage/labels" + } + } + } + }, "/admin/usage/log": { "get": { "tags": [ @@ -1742,7 +2029,7 @@ } }, { - "description": "Filter by model name", + "description": "Filter by exact model name", "name": "model", "in": "query", "schema": { @@ -1757,6 +2044,14 @@ "type": "string" } }, + { + "description": "Filter by request label (exact match)", + "name": "label", + "in": "query", + "schema": { + "type": "string" + } + }, { "description": "Filter by tracked user path subtree", "name": "user_path", @@ -1873,6 +2168,30 @@ "type": "string" } }, + { + "description": "Filter by exact model name", + "name": "model", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by provider name or provider type", + "name": "provider", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by request label (exact match)", + "name": "label", + "in": "query", + "schema": { + "type": "string" + } + }, { "description": "Filter by tracked user path subtree", "name": "user_path", @@ -2049,6 +2368,30 @@ "type": "string" } }, + { + "description": "Filter by exact model name", + "name": "model", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by provider name or provider type", + "name": "provider", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by request label (exact match)", + "name": "label", + "in": "query", + "schema": { + "type": "string" + } + }, { "description": "Filter by tracked user path subtree", "name": "user_path", @@ -2203,6 +2546,30 @@ "type": "string" } }, + { + "description": "Filter by exact model name", + "name": "model", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by provider name or provider type", + "name": "provider", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "description": "Filter by request label (exact match)", + "name": "label", + "in": "query", + "schema": { + "type": "string" + } + }, { "description": "Filter by tracked user path subtree", "name": "user_path", @@ -6938,6 +7305,22 @@ } } }, + "admin.taggingSettingsResponse": { + "type": "object", + "properties": { + "editable": { + "description": "Editable reports whether operator rules can be saved (false when no\nsettings storage is available).", + "type": "boolean" + }, + "headers": { + "description": "Headers is the effective rule set: config/env-managed rules (read-only,\nmanaged=true) followed by operator rules persisted in the admin store.", + "type": "array", + "items": { + "$ref": "#/components/schemas/tagging.Rule" + } + } + } + }, "admin.updateBudgetSettingsRequest": { "type": "object", "properties": { @@ -6967,6 +7350,17 @@ } } }, + "admin.updateTaggingSettingsRequest": { + "type": "object", + "properties": { + "headers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/tagging.Rule" + } + } + } + }, "admin.upsertBudgetRequest": { "type": "object", "properties": { @@ -7409,6 +7803,13 @@ } ] }, + "labels": { + "description": "Labels are request labels extracted from configured tagging headers.", + "type": "array", + "items": { + "type": "string" + } + }, "max_tokens": { "type": "integer" }, @@ -9334,6 +9735,31 @@ } } }, + "tagging.Rule": { + "type": "object", + "properties": { + "delimiter": { + "description": "Delimiter splits one header value into multiple labels. Default: \",\".", + "type": "string" + }, + "do_not_pass": { + "description": "DoNotPass strips the header before forwarding the request upstream.\nDefault: false (headers are passed through as-is).", + "type": "boolean" + }, + "header": { + "description": "Header is the canonical HTTP header name to read labels from.", + "type": "string" + }, + "managed": { + "description": "Managed marks a rule declared in config/env; such rules are read-only in\nthe dashboard. Never persisted.", + "type": "boolean" + }, + "prefix": { + "description": "Prefix is optionally trimmed from the front of each label. Trimming only\naffects the extracted label, never the forwarded header value.", + "type": "string" + } + } + }, "usage.CacheOverview": { "type": "object", "properties": { @@ -9442,6 +9868,38 @@ } } }, + "usage.LabelUsage": { + "type": "object", + "properties": { + "input_cost": { + "type": "number", + "nullable": true + }, + "input_tokens": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "output_cost": { + "type": "number", + "nullable": true + }, + "output_tokens": { + "type": "integer" + }, + "requests": { + "type": "integer" + }, + "total_cost": { + "type": "number", + "nullable": true + }, + "total_tokens": { + "type": "integer" + } + } + }, "usage.ModelUsage": { "type": "object", "properties": { @@ -9593,6 +10051,12 @@ "input_tokens": { "type": "integer" }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, "model": { "type": "string" }, diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index 8b847198..da2e6a5d 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -2866,6 +2866,12 @@ textarea:focus { margin-bottom: 16px; } +/* The request log carries many columns (labels included); scroll sideways on + narrow windows instead of clipping the trailing cost columns. */ +.usage-log-section .table-wrapper { + overflow-x: auto; +} + .usage-log-toolbar { display: grid; gap: 12px; @@ -2923,15 +2929,23 @@ textarea:focus { min-width: 0; } -.usage-filter-row-controls .usage-log-select { - grid-column: span 3; - min-width: 0; - width: 100%; +/* Page-level filter bar under the Usage Analytics header. Every widget on the + page (cards, charts, request log) follows these filters. */ +.usage-page-filters { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 20px; } -.usage-filter-row-controls .filter-input-wrap { - grid-column: span 6; - min-width: 0; +.usage-page-filters .usage-log-select { + flex: 0 1 auto; +} + +.usage-page-filters-user-path { + flex: 1 1 220px; + min-width: 180px; + max-width: 360px; } .usage-filter-row-options { @@ -2968,6 +2982,44 @@ textarea:focus { font-weight: 700; } +/* Request labels — one deterministic accent per label, shared with the + by-label chart. labelChipStyle() sets --label-color inline per chip. */ +.usage-label-chips { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; + max-width: 280px; +} + +.usage-label-chip { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 1px 9px; + border: 1px solid color-mix(in srgb, var(--label-color, var(--accent)) 45%, var(--border)); + border-radius: 999px; + background: color-mix(in srgb, var(--label-color, var(--accent)) 14%, var(--bg)); + color: var(--text); + font-size: 11px; + font-weight: 600; + font-family: inherit; + font-style: normal; + line-height: 1.4; + white-space: nowrap; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.usage-label-chip:hover { + background: color-mix(in srgb, var(--label-color, var(--accent)) 26%, var(--bg)); +} + +.usage-label-chip.active { + background: color-mix(in srgb, var(--label-color, var(--accent)) 32%, var(--bg)); + border-color: var(--label-color, var(--accent)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--label-color, var(--accent)) 55%, transparent); +} + /* Audit Log Section */ .audit-log-section { background: var(--bg-surface); @@ -5186,11 +5238,14 @@ body.conversation-drawer-open { .usage-log-select { min-width: 0; } - .usage-filter-row-search .filter-input-wrap, - .usage-filter-row-controls .usage-log-select, - .usage-filter-row-controls .filter-input-wrap { + .usage-filter-row-search .filter-input-wrap { grid-column: 1; } + .usage-page-filters .usage-log-select, + .usage-page-filters-user-path { + flex: 1 1 100%; + max-width: none; + } .usage-log-table { display: block; overflow-x: auto; diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index 2660a26b..9df3a1c9 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -156,16 +156,25 @@ function dashboard() { usageMode: "tokens", modelUsageView: "chart", userPathUsageView: "chart", + labelUsageView: "chart", modelUsage: [], userPathUsage: [], + labelUsage: [], usageLog: { entries: [], total: 0, limit: 50, offset: 0 }, + // Filtered summary for the usage-page stat cards (the overview page keeps + // its own unfiltered `summary`). + usageSummary: {}, + // Page-level data filters: drive every usage-page widget. + usageFilterModel: "", + usageFilterProvider: "", + usageFilterLabel: "", + usageFilterUserPath: "", + // Log-only view options. usageLogSearch: "", - usageLogModel: "", - usageLogProvider: "", - usageLogUserPath: "", usageLogHideCached: false, usageBarChart: null, usageUserPathChart: null, + usageLabelChart: null, // Audit page state auditLog: { entries: [], total: 0, limit: 25, offset: 0 }, @@ -370,6 +379,7 @@ function dashboard() { this.renderChart(); this.renderBarChart(); this.renderUserPathChart(); + this.renderLabelChart(); if (typeof this.redrawLiveTokensChart === "function") { // Force a rebuild so the bars pick up the new theme's colors. this.redrawLiveTokensChart(); diff --git a/internal/admin/dashboard/static/js/modules/charts.js b/internal/admin/dashboard/static/js/modules/charts.js index a88cb873..5e28c511 100644 --- a/internal/admin/dashboard/static/js/modules/charts.js +++ b/internal/admin/dashboard/static/js/modules/charts.js @@ -394,6 +394,10 @@ return this._usageRowsBySelectedValue(this.userPathUsage || []); }, + labelUsageTableRows() { + return this._usageRowsBySelectedValue(this.labelUsage || []); + }, + userPathUsageChartVisible() { const rows = Array.isArray(this.userPathUsage) ? this.userPathUsage : []; if (rows.length === 0) { @@ -416,6 +420,32 @@ return this._barDataFrom(this.userPathUsage || [], (u) => u.user_path || '/'); }, + _labelBarData() { + return this._barDataFrom(this.labelUsage || [], (l) => l.label); + }, + + // Deterministic label -> palette color so a label keeps one color + // across the bar chart and every chip on the page. + labelColor(label) { + const palette = this._barColors(); + let hash = 5381; + const text = String(label || ''); + for (let i = 0; i < text.length; i++) { + hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0; + } + return palette[Math.abs(hash) % palette.length]; + }, + + labelChipStyle(label) { + return { '--label-color': this.labelColor(label) }; + }, + + // The synthetic "Other" bar gets a neutral tone instead of a + // hashed identity color. + _labelBarPalette(labels) { + return labels.map((label) => label === 'Other' ? '#a8a29a' : this.labelColor(label)); + }, + barLegendItems() { const { labels, values } = this._barData(); const colors = this._barColors(); @@ -436,6 +466,12 @@ if (target === 'userPath') { this.userPathUsageView = view; this.renderUserPathChart(); + return; + } + + if (target === 'label') { + this.labelUsageView = view; + this.renderLabelChart(); } }, @@ -503,6 +539,39 @@ this.usageUserPathChart = new Chart(canvas, config); }); + }, + + renderLabelChart(retries) { + if (retries === undefined) retries = 3; + this.$nextTick(() => { + if ((this.labelUsage || []).length === 0 || this.page !== 'usage' || (this.labelUsageView || 'chart') !== 'chart') { + if (this.usageLabelChart) { + this.usageLabelChart.destroy(); + this.usageLabelChart = null; + } + return; + } + + const canvas = document.getElementById('usageLabelChart'); + if (!canvas || canvas.offsetWidth === 0) { + if (retries > 0) { + setTimeout(() => this.renderLabelChart(retries - 1), 100); + } + return; + } + + const colors = this.chartColors(); + const { labels, values } = this._labelBarData(); + const palette = this._labelBarPalette(labels); + const config = this._barChartConfig(colors, labels, values, palette); + + if (this.usageLabelChart) { + this.usageLabelChart.destroy(); + this.usageLabelChart = null; + } + + this.usageLabelChart = new Chart(canvas, config); + }); } }; } diff --git a/internal/admin/dashboard/static/js/modules/charts.test.cjs b/internal/admin/dashboard/static/js/modules/charts.test.cjs index 3f51a272..0179d7ad 100644 --- a/internal/admin/dashboard/static/js/modules/charts.test.cjs +++ b/internal/admin/dashboard/static/js/modules/charts.test.cjs @@ -263,3 +263,67 @@ test('toggleUsageChartView switches table and chart modes and rerenders chart vi assert.notEqual(module.usageUserPathChart, null); assert.notStrictEqual(module.usageUserPathChart, userPathChart); }); + +test('labelColor is deterministic and drawn from the shared palette', () => { + const { module } = createChartsContext(); + + assert.equal(module.labelColor('team-alpha'), module.labelColor('team-alpha')); + assert.ok(module._barColors().includes(module.labelColor('team-alpha'))); + assert.equal(module.labelChipStyle('x')['--label-color'], module.labelColor('x')); +}); + +test('renderLabelChart orders bars by selected metric and colors them per label', () => { + FakeChart.instances = []; + const { module } = createChartsContext(); + module.page = 'usage'; + module.usageMode = 'tokens'; + module.labelUsageView = 'chart'; + module.labelUsage = [ + { label: 'alpha', input_tokens: 5, output_tokens: 7, total_tokens: 12, total_cost: 0.01 }, + { label: 'prod', input_tokens: 11, output_tokens: 13, total_tokens: 24, total_cost: 0.02 } + ]; + + module.renderLabelChart(); + + assert.equal(FakeChart.instances.length, 1); + const chart = module.usageLabelChart; + assert.equal(JSON.stringify(chart.data.labels), JSON.stringify(['prod', 'alpha'])); + assert.equal(JSON.stringify(chart.data.datasets[0].data), JSON.stringify([24, 12])); + assert.equal( + JSON.stringify(chart.data.datasets[0].backgroundColor), + JSON.stringify([module.labelColor('prod'), module.labelColor('alpha')]) + ); + + module.labelUsage = []; + module.renderLabelChart(); + + assert.equal(chart.destroyCalls, 1); + assert.equal(module.usageLabelChart, null); +}); + +test('toggleUsageChartView label target switches views and rerenders', () => { + FakeChart.instances = []; + const { module } = createChartsContext(); + module.page = 'usage'; + module.usageMode = 'tokens'; + module.labelUsageView = 'chart'; + module.labelUsage = [ + { label: 'alpha', input_tokens: 10, output_tokens: 20, total_tokens: 30, total_cost: 0.01 } + ]; + + module.renderLabelChart(); + const labelChart = module.usageLabelChart; + assert.notEqual(labelChart, null); + + module.toggleUsageChartView('label', 'table'); + + assert.equal(module.labelUsageView, 'table'); + assert.equal(labelChart.destroyCalls, 1); + assert.equal(module.usageLabelChart, null); + + module.toggleUsageChartView('label', 'chart'); + + assert.equal(module.labelUsageView, 'chart'); + assert.notEqual(module.usageLabelChart, null); + assert.notStrictEqual(module.usageLabelChart, labelChart); +}); 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 3f445924..a6feec6a 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-display.test.cjs +++ b/internal/admin/dashboard/static/js/modules/dashboard-display.test.cjs @@ -184,6 +184,7 @@ test('system theme media changes rerender all dashboard charts', () => { let overviewCalls = 0; let modelCalls = 0; let userPathCalls = 0; + let labelCalls = 0; app.fetchAll = () => {}; app.renderChart = () => { @@ -195,6 +196,9 @@ test('system theme media changes rerender all dashboard charts', () => { app.renderUserPathChart = () => { userPathCalls++; }; + app.renderLabelChart = () => { + labelCalls++; + }; app.init(); assert.equal(typeof mediaChangeHandler, 'function'); @@ -202,11 +206,13 @@ test('system theme media changes rerender all dashboard charts', () => { overviewCalls = 0; modelCalls = 0; userPathCalls = 0; + labelCalls = 0; mediaChangeHandler(); assert.equal(overviewCalls, 1); assert.equal(modelCalls, 1); assert.equal(userPathCalls, 1); + assert.equal(labelCalls, 1); app.theme = 'dark'; mediaChangeHandler(); @@ -214,6 +220,7 @@ test('system theme media changes rerender all dashboard charts', () => { assert.equal(overviewCalls, 1); assert.equal(modelCalls, 1); assert.equal(userPathCalls, 1); + assert.equal(labelCalls, 1); }); test('unauthorized dashboard responses open the auth dialog', () => { 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 ff8e6bfa..008a5e36 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs +++ b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.cjs @@ -817,22 +817,26 @@ test("budget row record actions stack reset under edit", () => { assert.match(readCSSRule(css, ".budget-bar-fill-period-custom"), /background:\s*#bfa584/); }); -test("usage request log toolbar puts search above the remaining filters", () => { +test("usage page puts data filters below the header and keeps only search plus view options in the log toolbar", () => { const indexTemplate = readDashboardTemplateSource(); const css = readFixture("../../css/dashboard.css"); + // The page-level filter bar (model/provider/label/user-path) renders above + // the cache cards; the request log toolbar keeps search and the hide-cached + // toggle only. assert.match( indexTemplate, - /
\s*