mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
fix(webui): version display, CSP guards, config traps, bucket-aligned stacking
Review fix round (4 Important + 2 folded): 1. Version display: OverviewView.vue was missing the muted version line the old vanilla-JS page rendered (types/api.ts already declared overview.version) - added. 2. CSP invariant now guarded twice: webui/generate.mjs's new assertNoInlineAssets fails the build if dist/index.html contains an inline <script> (no src= attribute) or any <style> tag - the exact condition the tightened admin CSP (script-src/style-src 'self', no 'unsafe-inline') depends on. admin_test.go's new TestAdminIndexHTML_NoInlineAssets re-asserts the identical invariant server-side, against the decoded adminIndexHTML admin.go actually serves, so a regression is caught even if admin_assets_gen.go were regenerated with a modified copy of generate.mjs. 3. webui/components.json's iconLibrary:"lucide"/font:"inter" fields cannot be fixed in place - checked shadcn-vue's own schema (PRESET_ICON_LIBRARIES/PRESET_FONTS in the installed package): no "none"/fontawesome/system choice exists for either, and JSON (unlike the tsconfig.*.json files touched in the previous commit) has no comment syntax to warn inline. Documented instead: webui/README.md gained a loud warning section naming the exact regression (already hit twice this branch - a re-added Google Fonts import, a re-added lucide icon import) and the two-step fix to apply after any future shadcn-vue add/init call. 4. Real-CSP verification: the browser pass earlier in this branch ran under vite preview, which sends no CSP header at all. New integration/integration_test.go: TestAdminAssetCSP fetches GET /admin against the real Traefik+Yaegi pipeline, extracts the asset URL from its <script src="..."> tag, fetches that asset, and asserts both responses carry the exact adminCSP string byte-for-byte. admin.go's serveAdminAsset now also sets Content-Security-Policy (previously JSON/page routes only) so this holds for a direct navigation to an asset URL, not just as a same-origin sub-resource of GET /admin; admin_test.go's TestAdminAsset_Serving updated to match. 5. (folded) UsageChart.vue stacked the "tokens" tab's two series (tokin/tokout - two separate HTTP requests, not one atomic call) by positional index. A window rollover landing between the two requests could leave one series one bucket ahead, mis-stacking that refresh. Now aligns both series by their bucket key (a sorted union of every active series' buckets - the fixed-width, zero-padded bucket strings sort chronologically by construction), with a missing bucket in one series reading as 0 rather than shifting the whole series. 6. (folded) AuthGate.vue now triggers an immediate dashboard.refresh() on successful key submit, instead of waiting up to 5s for the next poll tick - the dashboard no longer sits blank right after auth. Rebuilt via make admin-ui; admin_assets_gen.go regenerated.
This commit is contained in:
@@ -839,15 +839,21 @@ or in CI.
|
|||||||
- **Response headers**: all three JSON routes set
|
- **Response headers**: all three JSON routes set
|
||||||
`X-Content-Type-Options: nosniff` and `Cache-Control: no-store`; every
|
`X-Content-Type-Options: nosniff` and `Cache-Control: no-store`; every
|
||||||
hashed asset sets `X-Content-Type-Options: nosniff` and its own
|
hashed asset sets `X-Content-Type-Options: nosniff` and its own
|
||||||
immutable `Cache-Control` (above). `GET /admin` and the three JSON
|
immutable `Cache-Control` (above). Every one of the four routes — `GET
|
||||||
routes share one `Content-Security-Policy` header (`default-src 'none';
|
/admin`, every hashed asset, and the three JSON routes — shares one
|
||||||
script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'
|
`Content-Security-Policy` header (`default-src 'none'; script-src
|
||||||
data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`) —
|
'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:;
|
||||||
no `'unsafe-inline'`, since the built app has no inline script or style
|
frame-ancestors 'none'; base-uri 'none'; form-action 'none'`) — no
|
||||||
of any kind, only external `/admin/assets/*` references; `img-src`
|
`'unsafe-inline'`, since the built app has no inline script or style of
|
||||||
additionally allows `data:` for the page's inlined favicon. The
|
any kind, only external `/admin/assets/*` references; `img-src`
|
||||||
dashboard loads no external asset of any kind and works in an
|
additionally allows `data:` for the page's inlined favicon. A browser
|
||||||
air-gapped cluster.
|
only enforces CSP against the top-level document that names it, so an
|
||||||
|
asset response's own header governs nothing when the browser fetched it
|
||||||
|
as a `<script src>`/`<link>` sub-resource of `GET /admin` — it matters
|
||||||
|
only if that asset URL is ever navigated to directly, and every other
|
||||||
|
admin route already sends the identical header, so there is no reason
|
||||||
|
for asset responses to be the exception. The dashboard loads no
|
||||||
|
external asset of any kind and works in an air-gapped cluster.
|
||||||
|
|
||||||
## MCP and A2A
|
## MCP and A2A
|
||||||
|
|
||||||
|
|||||||
@@ -160,7 +160,14 @@ func (g *Gateway) serveAdminPage(w http.ResponseWriter) {
|
|||||||
// produces new filenames, never mutates one this browser may have cached.
|
// produces new filenames, never mutates one this browser may have cached.
|
||||||
// X-Content-Type-Options: nosniff matches the JSON routes' own header
|
// X-Content-Type-Options: nosniff matches the JSON routes' own header
|
||||||
// (setAdminJSONHeaders) — the declared Content-Type must never be
|
// (setAdminJSONHeaders) — the declared Content-Type must never be
|
||||||
// second-guessed by the browser's MIME sniffer.
|
// second-guessed by the browser's MIME sniffer. Content-Security-Policy
|
||||||
|
// carries adminCSP too (review sweep): a browser only enforces CSP
|
||||||
|
// against the top-level document that names it, so this header governs
|
||||||
|
// nothing for a JS/CSS response fetched as a sub-resource of GET
|
||||||
|
// /admin — but a browser navigated STRAIGHT to an asset URL (a pasted
|
||||||
|
// link, a bookmark) treats that response as its own top-level document,
|
||||||
|
// and every other admin route already sends the identical header, so
|
||||||
|
// there is no reason for this one route to be the exception.
|
||||||
func (g *Gateway) serveAdminAsset(w http.ResponseWriter, name string) {
|
func (g *Gateway) serveAdminAsset(w http.ResponseWriter, name string) {
|
||||||
asset, ok := adminAssets[name]
|
asset, ok := adminAssets[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -170,6 +177,7 @@ func (g *Gateway) serveAdminAsset(w http.ResponseWriter, name string) {
|
|||||||
w.Header().Set("Content-Type", asset.contentType)
|
w.Header().Set("Content-Type", asset.contentType)
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
w.Header().Set("Content-Security-Policy", adminCSP)
|
||||||
_, _ = w.Write(asset.body)
|
_, _ = w.Write(asset.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+809
-805
File diff suppressed because it is too large
Load Diff
@@ -629,6 +629,9 @@ func TestAdminAsset_Serving(t *testing.T) {
|
|||||||
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||||
t.Errorf("X-Content-Type-Options = %q, want %q", got, "nosniff")
|
t.Errorf("X-Content-Type-Options = %q, want %q", got, "nosniff")
|
||||||
}
|
}
|
||||||
|
if got := rec.Header().Get("Content-Security-Policy"); got != adminCSP {
|
||||||
|
t.Errorf("Content-Security-Policy = %q, want %q", got, adminCSP)
|
||||||
|
}
|
||||||
cc := rec.Header().Get("Cache-Control")
|
cc := rec.Header().Get("Cache-Control")
|
||||||
if !strings.Contains(cc, "immutable") || !strings.Contains(cc, "max-age=31536000") {
|
if !strings.Contains(cc, "immutable") || !strings.Contains(cc, "max-age=31536000") {
|
||||||
t.Errorf("Cache-Control = %q, want a long-lived immutable directive", cc)
|
t.Errorf("Cache-Control = %q, want a long-lived immutable directive", cc)
|
||||||
@@ -692,6 +695,36 @@ func TestAdminAssets_GeneratedFilePresent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adminInlineScriptRe matches every <script ...> opening tag, capturing its
|
||||||
|
// attribute text — TestAdminIndexHTML_NoInlineAssets checks each capture for
|
||||||
|
// a src= attribute.
|
||||||
|
var adminInlineScriptRe = regexp.MustCompile(`(?i)<script\b([^>]*)>`)
|
||||||
|
|
||||||
|
// TestAdminIndexHTML_NoInlineAssets asserts, server-side, the exact CSP
|
||||||
|
// invariant generate.mjs's own assertNoInlineAssets checks at build time
|
||||||
|
// (webui/generate.mjs): the decoded adminIndexHTML — what admin.go's
|
||||||
|
// serveAdminPage actually serves — contains no inline <script> (a
|
||||||
|
// <script> tag with no src= attribute) and no <style> tag anywhere. The
|
||||||
|
// admin CSP (adminCSP, above) ships script-src/style-src 'self' with no
|
||||||
|
// 'unsafe-inline'; either violation would silently break under that
|
||||||
|
// policy. This test exists as a second, independent guard: it catches a
|
||||||
|
// regression even if admin_assets_gen.go were regenerated with a
|
||||||
|
// modified copy of generate.mjs that dropped or weakened its own check.
|
||||||
|
func TestAdminIndexHTML_NoInlineAssets(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
html := string(adminIndexHTML)
|
||||||
|
|
||||||
|
for _, m := range adminInlineScriptRe.FindAllStringSubmatch(html, -1) {
|
||||||
|
attrs := m[1]
|
||||||
|
if !strings.Contains(strings.ToLower(attrs), "src=") {
|
||||||
|
t.Errorf("adminIndexHTML contains an inline <script> (no src= attribute): %q", m[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(html), "<style") {
|
||||||
|
t.Error("adminIndexHTML contains a <style> tag — every style must be an external <link rel=\"stylesheet\">")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func truncateForTest(b []byte, n int) string {
|
func truncateForTest(b []byte, n int) string {
|
||||||
if len(b) <= n {
|
if len(b) <= n {
|
||||||
return string(b)
|
return string(b)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -946,6 +947,70 @@ func TestAudioTranscriptions(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adminCSPHeader mirrors admin.go's adminCSP constant byte-for-byte. This
|
||||||
|
// package is a separate Go module (integration/go.mod) and cannot import
|
||||||
|
// the plugin's unexported const, so the value is duplicated by hand;
|
||||||
|
// TestAdminAssetCSP below compares it against a real header the running
|
||||||
|
// plugin sent, so a drift between the two shows up as a test failure
|
||||||
|
// immediately, not as a silent divergence.
|
||||||
|
const adminCSPHeader = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
|
||||||
|
|
||||||
|
// adminAssetSrcRe extracts the `src` attribute of the first `<script>` tag
|
||||||
|
// in the served /admin shell — the built Vue app's own entry-point asset
|
||||||
|
// reference.
|
||||||
|
var adminAssetSrcRe = regexp.MustCompile(`<script[^>]+src="([^"]+)"`)
|
||||||
|
|
||||||
|
// TestAdminAssetCSP proves the Content-Security-Policy header under the
|
||||||
|
// REAL, shipped pipeline: a Vite build baked into the plugin binary by
|
||||||
|
// webui/generate.mjs, served by a real Traefik v3.5 process interpreting
|
||||||
|
// the plugin with Yaegi — not the in-process httptest handler
|
||||||
|
// admin_test.go's unit tests use. It fetches GET /admin, extracts the
|
||||||
|
// asset URL its own <script src="..."> tag names, fetches that asset, and
|
||||||
|
// asserts both responses carry the exact adminCSP string. Combined with
|
||||||
|
// generate.mjs's own build-time assertion and admin_test.go's
|
||||||
|
// TestAdminIndexHTML_NoInlineAssets (both check the CSP invariant holds —
|
||||||
|
// no inline script/style — this test checks the header itself is what
|
||||||
|
// actually reaches the wire), the loop from "build" to "real HTTP
|
||||||
|
// response" is closed.
|
||||||
|
func TestAdminAssetCSP(t *testing.T) {
|
||||||
|
pageResp, err := http.Get(traefik1URL + "/admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /admin: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = pageResp.Body.Close() }()
|
||||||
|
if pageResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("GET /admin: status = %d, want 200", pageResp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := pageResp.Header.Get("Content-Security-Policy"); got != adminCSPHeader {
|
||||||
|
t.Errorf("GET /admin: Content-Security-Policy = %q, want %q", got, adminCSPHeader)
|
||||||
|
}
|
||||||
|
pageBody, err := io.ReadAll(pageResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read /admin body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
match := adminAssetSrcRe.FindSubmatch(pageBody)
|
||||||
|
if match == nil {
|
||||||
|
t.Fatalf("GET /admin: body has no <script src=\"...\"> reference, body=%s", pageBody)
|
||||||
|
}
|
||||||
|
assetPath := string(match[1])
|
||||||
|
if !strings.HasPrefix(assetPath, "/admin/assets/") {
|
||||||
|
t.Fatalf("extracted asset src = %q, want a path under /admin/assets/", assetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
assetResp, err := http.Get(traefik1URL + assetPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET %s: %v", assetPath, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = assetResp.Body.Close() }()
|
||||||
|
if assetResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("GET %s: status = %d, want 200", assetPath, assetResp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := assetResp.Header.Get("Content-Security-Policy"); got != adminCSPHeader {
|
||||||
|
t.Errorf("GET %s: Content-Security-Policy = %q, want %q", assetPath, got, adminCSPHeader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAdminDashboard covers v0.2 integration coverage (spec §4): the
|
// TestAdminDashboard covers v0.2 integration coverage (spec §4): the
|
||||||
// unauthenticated HTML shell carries no data, the two JSON routes gate
|
// unauthenticated HTML shell carries no data, the two JSON routes gate
|
||||||
// on admin vs. non-admin vs. unauthenticated, and GET /admin/api/usage
|
// on admin vs. non-admin vs. unauthenticated, and GET /admin/api/usage
|
||||||
|
|||||||
@@ -10,6 +10,36 @@ only way this directory's output (`../admin_assets_gen.go`) is meant to be
|
|||||||
regenerated. Nothing here runs at request time: the plugin serves the
|
regenerated. Nothing here runs at request time: the plugin serves the
|
||||||
built, baked-in output, never `webui/` itself.
|
built, baked-in output, never `webui/` itself.
|
||||||
|
|
||||||
|
## Warning: `shadcn-vue add`/`init` regressions
|
||||||
|
|
||||||
|
`components.json`'s `iconLibrary` and `font` fields are locked to
|
||||||
|
`"lucide"` and `"inter"` — **not this project's real choices** (this
|
||||||
|
project uses FontAwesome only, per `vue-web-development` house skill, and
|
||||||
|
no external font at all). The shadcn-vue CLI's schema does not accept
|
||||||
|
`"none"`/`"fontawesome"`/`"system"` for either field (checked directly:
|
||||||
|
`node_modules/shadcn-vue/dist/preset-*.js`'s `PRESET_ICON_LIBRARIES` and
|
||||||
|
`PRESET_FONTS` arrays — five fixed choices each, no escape hatch), and
|
||||||
|
JSON supports no comment syntax to warn inline (the repo's own
|
||||||
|
pre-commit JSON validator rejects JSONC comments — see `tsconfig.*.json`
|
||||||
|
history). This warning is the only place the trap could be recorded.
|
||||||
|
|
||||||
|
Both `npx shadcn-vue init` and `npx shadcn-vue add <component>` read
|
||||||
|
these fields and **silently rewrite `src/assets/main.css`** — hit twice
|
||||||
|
in this project's own history: `init` first, then a follow-up `add`
|
||||||
|
call, both re-added a `@import url('https://fonts.googleapis.com/...')`
|
||||||
|
line and switched the dark-mode `@custom-variant` back to a `.dark`-class
|
||||||
|
toggle. If you ever run either command again:
|
||||||
|
|
||||||
|
1. **Diff `src/assets/main.css` before committing.** Remove any
|
||||||
|
re-added `@import url('https://fonts.googleapis...')` line (external
|
||||||
|
font, blocked by the admin CSP's `style-src 'self'` anyway) and
|
||||||
|
restore the `@custom-variant dark` removal (see the file's own header
|
||||||
|
comment for why: `prefers-color-scheme`, not a class toggle).
|
||||||
|
2. **Check the new component for a `lucide-vue-next`/`@lucide/vue`
|
||||||
|
import** and swap it for the FontAwesome equivalent (see
|
||||||
|
`src/components/ui/select/Select*.vue` for the pattern already
|
||||||
|
applied there).
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -61,6 +61,43 @@ function walk(dir, base = dir) {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* assertNoInlineAssets fails the build if html contains an inline
|
||||||
|
* `<script>` (any `<script>` tag with no `src=` attribute) or any
|
||||||
|
* `<style>` tag. The admin CSP (admin.go's adminCSP) ships `script-src
|
||||||
|
* 'self'` and `style-src 'self'` with no `'unsafe-inline'` — that is only
|
||||||
|
* safe because `vite build`'s own output never needs it (an external
|
||||||
|
* `<script type="module" src="...">` plus an external `<link
|
||||||
|
* rel="stylesheet">`, nothing inline). This check is generate.mjs's own
|
||||||
|
* guard against that invariant silently breaking under a future Vite/
|
||||||
|
* plugin config change; admin_test.go's TestAdminIndexHTML_NoInlineAssets
|
||||||
|
* re-asserts the identical invariant server-side, against the DECODED
|
||||||
|
* adminIndexHTML admin.go actually serves — so a corrupted or
|
||||||
|
* hand-patched admin_assets_gen.go is still caught even if someone
|
||||||
|
* regenerates with a modified copy of this script.
|
||||||
|
*/
|
||||||
|
function assertNoInlineAssets(html, sourceLabel) {
|
||||||
|
const scriptTagRe = /<script\b([^>]*)>/gi
|
||||||
|
let match
|
||||||
|
while ((match = scriptTagRe.exec(html)) !== null) {
|
||||||
|
if (!/\bsrc\s*=/i.test(match[1])) {
|
||||||
|
console.error(
|
||||||
|
`generate.mjs: ${sourceLabel} contains an inline <script> (no src= attribute): ${match[0]}\n` +
|
||||||
|
`The admin CSP ships script-src 'self' with no 'unsafe-inline' — every script must be external.`,
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/<style\b/i.test(html)) {
|
||||||
|
console.error(
|
||||||
|
`generate.mjs: ${sourceLabel} contains a <style> tag.\n` +
|
||||||
|
`The admin CSP ships style-src 'self' with no 'unsafe-inline' — every style must be an external <link rel="stylesheet">.`,
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/** goStringLiteral renders a Go double-quoted string literal for s, escaping the handful of characters that require it. base64 output never needs this (its alphabet is a strict subset of what a Go string literal permits unescaped), but this stays generic rather than assuming that forever. */
|
/** goStringLiteral renders a Go double-quoted string literal for s, escaping the handful of characters that require it. base64 output never needs this (its alphabet is a strict subset of what a Go string literal permits unescaped), but this stays generic rather than assuming that forever. */
|
||||||
function goStringLiteral(s) {
|
function goStringLiteral(s) {
|
||||||
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||||
@@ -123,6 +160,10 @@ function main() {
|
|||||||
assetEntries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
assetEntries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
||||||
|
|
||||||
const indexBody = readFileSync(join(DIST_DIR, indexRel))
|
const indexBody = readFileSync(join(DIST_DIR, indexRel))
|
||||||
|
if (!assertNoInlineAssets(indexBody.toString('utf-8'), `${DIST_DIR}/${indexRel}`)) {
|
||||||
|
process.exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
const indexBase64 = indexBody.toString('base64')
|
const indexBase64 = indexBody.toString('base64')
|
||||||
|
|
||||||
const sourceDigest = createHash('sha256')
|
const sourceDigest = createHash('sha256')
|
||||||
|
|||||||
@@ -7,14 +7,21 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useDashboardStore } from '@/stores/dashboard'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const dashboard = useDashboardStore()
|
||||||
const keyInput = ref('')
|
const keyInput = ref('')
|
||||||
|
|
||||||
function submit(): void {
|
function submit(): void {
|
||||||
const value = keyInput.value
|
const value = keyInput.value
|
||||||
keyInput.value = ''
|
keyInput.value = ''
|
||||||
auth.submit(value)
|
auth.submit(value)
|
||||||
|
// Fetch immediately rather than waiting for the next 5s poll tick
|
||||||
|
// (dashboard.startPolling's own interval, already running from App.vue's
|
||||||
|
// onMounted) — otherwise a freshly authenticated dashboard can sit blank
|
||||||
|
// for up to 5s after key entry.
|
||||||
|
void dashboard.refresh()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -144,5 +144,7 @@ const overview = computed(() => dashboard.overview)
|
|||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<p v-if="overview?.version" class="text-xs text-muted-foreground">Version: {{ overview.version }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -32,12 +32,29 @@ function toDisplayValue(metric: HistoryMetric, raw: number): number {
|
|||||||
return metric === 'cost' ? raw / 1_000_000 : raw
|
return metric === 'cost' ? raw / 1_000_000 : raw
|
||||||
}
|
}
|
||||||
|
|
||||||
const labels = computed<string[]>(() => {
|
/**
|
||||||
|
* canonicalBuckets is the union of every active dataset's bucket keys,
|
||||||
|
* sorted. The "tokens" tab fetches tokin and tokout as two separate HTTP
|
||||||
|
* requests (history.ts's metricsForTab) — not one atomic call — so a
|
||||||
|
* window rollover landing between the two requests can leave one series
|
||||||
|
* one bucket ahead of the other. Zipping the two arrays positionally
|
||||||
|
* would silently mis-stack that refresh's chart. Bucket strings are
|
||||||
|
* fixed-width, zero-padded digit strings (limits.go's windowKey: hour
|
||||||
|
* "2006010215", day "20060102", month "200601"), so a plain lexicographic
|
||||||
|
* sort is also a chronological sort — no date parsing needed to align
|
||||||
|
* them.
|
||||||
|
*/
|
||||||
|
const canonicalBuckets = computed<string[]>(() => {
|
||||||
const specs = DATASET_SPECS[props.tab]
|
const specs = DATASET_SPECS[props.tab]
|
||||||
const points = props.seriesByMetric[specs[0]!.metric] ?? []
|
const bucketSet = new Set<string>()
|
||||||
return points.map((p) => formatBucketLabel(p.bucket, props.window))
|
for (const spec of specs) {
|
||||||
|
for (const p of props.seriesByMetric[spec.metric] ?? []) bucketSet.add(p.bucket)
|
||||||
|
}
|
||||||
|
return Array.from(bucketSet).sort()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const labels = computed<string[]>(() => canonicalBuckets.value.map((b) => formatBucketLabel(b, props.window)))
|
||||||
|
|
||||||
/** resolveToken reads one of main.css's --chart-* custom properties from :root, live — so a chart already on screen repaints on a `prefers-color-scheme` flip exactly like useThemeColors does for axis/legend text. */
|
/** resolveToken reads one of main.css's --chart-* custom properties from :root, live — so a chart already on screen repaints on a `prefers-color-scheme` flip exactly like useThemeColors does for axis/legend text. */
|
||||||
function resolveToken(cssVar: string): string {
|
function resolveToken(cssVar: string): string {
|
||||||
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
|
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
|
||||||
@@ -48,13 +65,24 @@ const chartData = computed<ChartData<'bar'>>(() => {
|
|||||||
// custom properties via resolveToken) on a `prefers-color-scheme` flip —
|
// custom properties via resolveToken) on a `prefers-color-scheme` flip —
|
||||||
// resolveToken's own getComputedStyle read is not itself reactive.
|
// resolveToken's own getComputedStyle read is not itself reactive.
|
||||||
void foreground.value
|
void foreground.value
|
||||||
|
const buckets = canonicalBuckets.value
|
||||||
return {
|
return {
|
||||||
labels: labels.value,
|
labels: labels.value,
|
||||||
datasets: DATASET_SPECS[props.tab].map((spec) => ({
|
datasets: DATASET_SPECS[props.tab].map((spec) => {
|
||||||
label: spec.label,
|
// Map<bucket, value> per series, so each canonical bucket looks up
|
||||||
backgroundColor: resolveToken(spec.color),
|
// its own value rather than assuming index i means the same bucket
|
||||||
data: (props.seriesByMetric[spec.metric] ?? []).map((p) => toDisplayValue(spec.metric, p.value)),
|
// across every dataset (the mis-stacking canonicalBuckets exists to
|
||||||
})),
|
// avoid — see its own doc comment). A bucket this series has no
|
||||||
|
// point for reads as 0, matching the zero-based axis: a genuinely
|
||||||
|
// missing bucket (not yet reported by the store) is indistinguishable
|
||||||
|
// from zero usage, which is the honest reading for a bar chart.
|
||||||
|
const byBucket = new Map((props.seriesByMetric[spec.metric] ?? []).map((p) => [p.bucket, p.value]))
|
||||||
|
return {
|
||||||
|
label: spec.label,
|
||||||
|
backgroundColor: resolveToken(spec.color),
|
||||||
|
data: buckets.map((b) => toDisplayValue(spec.metric, byBucket.get(b) ?? 0)),
|
||||||
|
}
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user