mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user