diff --git a/README.md b/README.md
index a6f09c5..efba24a 100644
--- a/README.md
+++ b/README.md
@@ -839,15 +839,21 @@ or in CI.
- **Response headers**: all three JSON routes set
`X-Content-Type-Options: nosniff` and `Cache-Control: no-store`; every
hashed asset sets `X-Content-Type-Options: nosniff` and its own
- immutable `Cache-Control` (above). `GET /admin` and the three JSON
- routes share one `Content-Security-Policy` header (`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'`) —
- no `'unsafe-inline'`, since the built app has no inline script or style
- of any kind, only external `/admin/assets/*` references; `img-src`
- additionally allows `data:` for the page's inlined favicon. The
- dashboard loads no external asset of any kind and works in an
- air-gapped cluster.
+ immutable `Cache-Control` (above). Every one of the four routes — `GET
+ /admin`, every hashed asset, and the three JSON routes — shares one
+ `Content-Security-Policy` header (`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'`) — no
+ `'unsafe-inline'`, since the built app has no inline script or style of
+ any kind, only external `/admin/assets/*` references; `img-src`
+ additionally allows `data:` for the page's inlined favicon. A browser
+ 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 `
diff --git a/webui/src/components/OverviewView.vue b/webui/src/components/OverviewView.vue
index b2143bc..afcf3b0 100644
--- a/webui/src/components/OverviewView.vue
+++ b/webui/src/components/OverviewView.vue
@@ -144,5 +144,7 @@ const overview = computed(() => dashboard.overview)
+
+
Version: {{ overview.version }}
diff --git a/webui/src/components/UsageChart.vue b/webui/src/components/UsageChart.vue
index eac55f2..130b2f4 100644
--- a/webui/src/components/UsageChart.vue
+++ b/webui/src/components/UsageChart.vue
@@ -32,12 +32,29 @@ function toDisplayValue(metric: HistoryMetric, raw: number): number {
return metric === 'cost' ? raw / 1_000_000 : raw
}
-const labels = computed(() => {
+/**
+ * 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(() => {
const specs = DATASET_SPECS[props.tab]
- const points = props.seriesByMetric[specs[0]!.metric] ?? []
- return points.map((p) => formatBucketLabel(p.bucket, props.window))
+ const bucketSet = new Set()
+ 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(() => 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. */
function resolveToken(cssVar: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
@@ -48,13 +65,24 @@ const chartData = computed>(() => {
// custom properties via resolveToken) on a `prefers-color-scheme` flip —
// resolveToken's own getComputedStyle read is not itself reactive.
void foreground.value
+ const buckets = canonicalBuckets.value
return {
labels: labels.value,
- datasets: DATASET_SPECS[props.tab].map((spec) => ({
- label: spec.label,
- backgroundColor: resolveToken(spec.color),
- data: (props.seriesByMetric[spec.metric] ?? []).map((p) => toDisplayValue(spec.metric, p.value)),
- })),
+ datasets: DATASET_SPECS[props.tab].map((spec) => {
+ // Map per series, so each canonical bucket looks up
+ // its own value rather than assuming index i means the same bucket
+ // 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)),
+ }
+ }),
}
})