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:
2026-08-21 09:43:24 +01:00
parent afdb7415db
commit b98416d1e9
10 changed files with 1047 additions and 823 deletions
+30
View File
@@ -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
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
```sh
+41
View File
@@ -61,6 +61,43 @@ function walk(dir, base = dir) {
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. */
function goStringLiteral(s) {
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))
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 sourceDigest = createHash('sha256')
+7
View File
@@ -7,14 +7,21 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { useAuthStore } from '@/stores/auth'
import { useDashboardStore } from '@/stores/dashboard'
const auth = useAuthStore()
const dashboard = useDashboardStore()
const keyInput = ref('')
function submit(): void {
const value = keyInput.value
keyInput.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>
+2
View File
@@ -144,5 +144,7 @@ const overview = computed(() => dashboard.overview)
</Table>
</CardContent>
</Card>
<p v-if="overview?.version" class="text-xs text-muted-foreground">Version: {{ overview.version }}</p>
</div>
</template>
+36 -8
View File
@@ -32,12 +32,29 @@ function toDisplayValue(metric: HistoryMetric, raw: number): number {
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 points = props.seriesByMetric[specs[0]!.metric] ?? []
return points.map((p) => formatBucketLabel(p.bucket, props.window))
const bucketSet = new Set<string>()
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. */
function resolveToken(cssVar: string): string {
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 —
// 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<bucket, value> 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)),
}
}),
}
})