feat(webui): add limit bars, forecasts, events, exports and URL state

The Usage tab shows a bar for each configured limit. It turns amber at
80% and red at 100%. The tab also shows rejected requests per day, a
projected month-end cost with budget headroom, and CSV export for users
and groups. Names link to Charts with the matching scope.

The Charts tab ranks models over the full 24h, 30d or 12mo window. A
provider filter is applied on the server, and the ranking can be
exported as CSV. A new Events tab lists recent failures, filtered by
kind and user. The selected tab, filters and scope live in the URL
hash. Polling stops while the page is hidden. Views show which replica
answered, and a banner lists configuration warnings.

CSV cells that start with a formula character are neutralised. Links
sit outside the accordion buttons, so both controls keep their
accessible names. admin_assets_gen.go is regenerated.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 13:09:57 +01:00
parent 25e6a7779e
commit 4261efc41b
53 changed files with 4777 additions and 238 deletions
+73 -13
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { faXmark } from '@fortawesome/free-solid-svg-icons'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import SearchInput from '@/components/SearchInput.vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
@@ -11,19 +13,23 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import CsvExportButton from '@/components/CsvExportButton.vue'
import ModelUsageChart from '@/components/ModelUsageChart.vue'
import UsageChart from '@/components/UsageChart.vue'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { filterModelsByPrefix } from '@/lib/model-filter'
import { modelRankingCsv } from '@/lib/usage-csv'
import { groupMatches, userMatches } from '@/lib/usage-search'
import { useDashboardStore } from '@/stores/dashboard'
import {
type ChartTab,
MODEL_METRIC_LABEL,
MODEL_WINDOW_LABEL,
MODEL_RANKING_LIMIT,
type ModelMetric,
type TimeSeriesTab,
useHistoryStore,
WINDOW_LABEL,
WINDOW_SPAN,
} from '@/stores/history'
import type { HistoryWindow } from '@/types/api'
@@ -81,15 +87,40 @@ const scopeOptions = computed(() => {
const windows: HistoryWindow[] = ['hour', 'day', 'month']
const modelMetrics: ModelMetric[] = ['cost', 'req', 'tokin', 'tokout']
// --- Models-tab prefix filter (F6, dashboard-plan.md) ---
//
// history.modelFilter is set by ProvidersView.vue's provider-header link
// (nav.goToModels(`${p.name}/`), stores/nav.ts — WP-B1) or restored from
// the `#charts?tab=models&filter=...` hash (lib/hash-state.ts). P3 review
// fix: setModelFilter now also sends the prefix to the SERVER and
// refetches (stores/history.ts's own state doc comment on modelFilter),
// so this re-slice is a harmless second pass over an already-narrowed
// ranking, not the only filtering step — see lib/model-filter.ts's own
// doc comment.
const filteredModelRanking = computed(() => filterModelsByPrefix(history.modelRanking, history.modelFilter))
/**
* windowLabel picks the right window-tab text for whichever query is
* actually active: the Models tab reads a single current bucket (review
* finding — MODEL_WINDOW_LABEL's own doc comment, stores/history.ts), the
* other three tabs read a real span (WINDOW_LABEL). Same Tabs control
* either way — only the label changes.
* Exports exactly the ranking rows on screen (filter applied, server order
* kept). P6 review fix: passes the resolved DISPLAY label constants
* (MODEL_METRIC_LABEL/WINDOW_LABEL), not the raw enum values
* ("cost"/"hour") modelRankingCsv's own doc comment always said the caller
* should — the header used to read the literal "cost (hour, span 24)"
* with no stated unit. `isCostMetric` states whether the CURRENTLY ranked
* metric is cost, so modelRankingCsv can convert the raw micro-USD `value`
* to a plain decimal USD number and name the unit in the header.
*/
function windowLabel(window: HistoryWindow): string {
return history.tab === 'models' ? MODEL_WINDOW_LABEL[window] : WINDOW_LABEL[window]
function modelsCsv(): string {
return modelRankingCsv(
filteredModelRanking.value,
MODEL_METRIC_LABEL[history.modelMetric],
WINDOW_LABEL[history.window],
WINDOW_SPAN[history.window],
history.modelMetric === 'cost',
)
}
function clearModelFilter(): void {
history.setModelFilter('')
}
/**
@@ -111,7 +142,13 @@ function onModelMetricChange(value: unknown): void {
}
onMounted(() => {
void history.refresh()
// The picker's model list is fetched here (initial load) and again on
// any window change (history.ts's own setWindow) — nowhere else (Q1,
// dashboard-plan.md DECISIONS). The selection's own series/ranking
// fetch no longer needs a separate call here: startAutoRefresh's poller
// (lib/polling.ts) ticks once immediately on start, which is that
// initial fetch.
void history.fetchModelOptions()
history.startAutoRefresh()
})
onUnmounted(() => {
@@ -120,7 +157,16 @@ onUnmounted(() => {
// The scope list is only known once the Usage response has loaded — if the
// previously-selected scope disappears (a user/group config change), fall
// back to the total scope rather than requesting a now-unknown id.
// back to the total scope rather than requesting a now-unknown id. This is
// ALSO what guards a bogus scope restored from a hand-edited URL hash (F9,
// dashboard-plan.md: lib/hash-state.ts's parseChartsParams only validates
// SHAPE — "total"/"user:.+"/"group:.+"/"model:.+" — never that the id
// actually exists): `watch` without `{ immediate: true }` skips its first
// evaluation, so a hash-restored scope like "user:ghost" survives the
// (likely still-empty, pre-fetch) initial scopeOptions and is only judged
// once dashboard.usage genuinely loads and this computed's value actually
// changes — at which point a truly nonexistent id resets to 'total' here,
// exactly like any other vanished scope.
watch(scopeOptions, (options) => {
if (!options.some((o) => o.value === history.scope)) {
history.setScope('total')
@@ -169,7 +215,7 @@ watch(scopeOptions, (options) => {
<Tabs :model-value="history.window" @update:model-value="(v) => history.setWindow(v as HistoryWindow)">
<TabsList>
<TabsTrigger v-for="w in windows" :key="w" :value="w">{{ windowLabel(w) }}</TabsTrigger>
<TabsTrigger v-for="w in windows" :key="w" :value="w">{{ WINDOW_LABEL[w] }}</TabsTrigger>
</TabsList>
</Tabs>
</div>
@@ -189,11 +235,25 @@ watch(scopeOptions, (options) => {
<p v-else-if="history.loading && !history.loaded" class="text-sm text-muted-foreground">Loading…</p>
<template v-if="history.tab === 'models'">
<p class="text-sm text-muted-foreground">
Models with usage in the selected window, ranked by
Models with usage over the last {{ WINDOW_LABEL[history.window] }}, ranked by
{{ MODEL_METRIC_LABEL[history.modelMetric].toLowerCase() }}. Each row is the provider that served the
traffic, so a model reached through more than one provider appears once per provider.
</p>
<ModelUsageChart :metric="history.modelMetric" :models="history.modelRanking" />
<p v-if="history.modelFilter" class="flex items-center gap-1.5 text-sm">
<span class="text-muted-foreground">Filtered to</span>
<span class="font-mono text-xs">{{ history.modelFilter }}</span>
<Button variant="ghost" size="icon-xs" aria-label="Clear model filter" @click="clearModelFilter">
<FontAwesomeIcon :icon="faXmark" class="size-3.5" aria-hidden="true" />
</Button>
</p>
<!-- P3: the ranking is a top-N (MODEL_RANKING_LIMIT), never the complete set — say so whenever the fetched ranking is exactly that long, so a full list doesn't silently read as "this is everything". -->
<p v-if="history.modelRanking.length === MODEL_RANKING_LIMIT" class="text-xs text-muted-foreground">
Top {{ MODEL_RANKING_LIMIT }} models
</p>
<ModelUsageChart :metric="history.modelMetric" :models="filteredModelRanking" />
<div class="flex justify-end">
<CsvExportButton :filename="`usage-models-${history.window}.csv`" :build="modelsCsv" />
</div>
</template>
<UsageChart v-else :tab="timeSeriesTab" :window="history.window" :series-by-metric="history.seriesByMetric" />
</CardContent>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
import { ref } from 'vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
/**
* ConfigWarningsBanner surfaces GET /admin/api/overview's `warnings`/
* `warningsDropped` (F10, dashboard-plan.md — promoted warnf calls
* collected during newGateway's own construction window: metrics.path
* collisions, a zero cache ttl, multi-provider registry ids, retry x
* failover). Renders nothing at all for an empty `warnings` array — App.vue
* only mounts this under the header when there is something to show, but
* the component stays self-guarding too so it is safe to render
* unconditionally.
*/
const props = defineProps<{
warnings: string[]
/** Warnings logged beyond the server's own cap (configWarningsCap, llmgateway.go) that were dropped, not kept — omitted (undefined) entirely when nothing was dropped. */
warningsDropped?: number
}>()
/** Collapsed by default: the header/status line already reads cleanly, and a config warning is something an operator investigates once, not something that needs to stay open on every visit. */
const expanded = ref(false)
function toggleExpanded(): void {
expanded.value = !expanded.value
}
</script>
<template>
<Alert v-if="props.warnings.length > 0" variant="warn">
<FontAwesomeIcon :icon="faTriangleExclamation" class="size-4" aria-hidden="true" />
<AlertTitle class="flex items-center justify-between gap-2">
<span>{{ props.warnings.length }} configuration {{ props.warnings.length === 1 ? 'warning' : 'warnings' }}</span>
<!--
P11 review fix (WCAG 2.5.3 Label in Name): the aria-label used to
be the fixed text "Toggle configuration warning details", which
shares NO words with the visible "Show"/"Hide" label a sighted
reader sees — a speech-input user saying "click show" would not
match this control's accessible name at all. The aria-label now
STARTS WITH the same word the button visibly displays, so the
accessible name always contains the visible label.
-->
<Button
type="button"
variant="ghost"
size="xs"
:aria-expanded="expanded"
:aria-label="`${expanded ? 'Hide' : 'Show'} configuration warning details`"
@click="toggleExpanded"
>
{{ expanded ? 'Hide' : 'Show' }}
</Button>
</AlertTitle>
<AlertDescription v-if="expanded">
<ul class="mt-1 list-disc space-y-0.5 pl-4 font-mono text-xs">
<li v-for="(warning, i) in props.warnings" :key="i">{{ warning }}</li>
</ul>
<p v-if="props.warningsDropped" class="mt-1 text-xs">and {{ props.warningsDropped }} more were not kept</p>
</AlertDescription>
</Alert>
</template>
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
import { formatCost } from '@/lib/format'
import type { LimitsConfig } from '@/types/api'
/**
* CostForecastCard renders the Usage tab's fleet-wide cost projection —
* F7, placed under the Total card (UsageView.vue). All math is delegated
* to lib/forecast.ts's pure functions; this component only formats and
* lays the result out.
*
* `now` is a REQUIRED prop, not read from `new Date()` inside this
* component (coordinator brief: "now passed as prop for determinism") —
* the caller (UsageView.vue) supplies it, keeping every `Date.now()`-
* flavored read in this feature at the boundary rather than buried inside
* a component, same discipline lib/forecast.ts's own functions already
* follow by taking `now`/`elapsedFraction` as plain arguments.
*
* P1 review fix: this used to be optional with a `withDefaults(...,
* { now: () => new Date() })` default. Vue caches a prop's default value
* ONCE per component instance (propsDefaults) — the very first time it is
* read, not on every render — so a caller that omitted `now` froze this
* card's projection at whatever instant it happened to mount, never
* advancing again for as long as the tab stayed open. `now` is now
* required: UsageView.vue passes the ONE shared reactive clock
* (composables/useNow.ts) every time-dependent read on this tab shares,
* so there is no default left to accidentally freeze.
*/
const props = defineProps<{
/** usage.total.costPerMonthMicroUsd — fleet-wide month-to-date spend. */
mtdMicros: number
/** usage.total.limits — costPerMonthUSD is the only field this card reads. */
limits?: LimitsConfig
/** The shared reactive clock (composables/useNow.ts) — never defaulted here, see this comment block above. */
now: Date
}>()
const elapsedFraction = computed(() => monthProgress(props.now))
const projectedMicros = computed(() => projectMonthEnd(props.mtdMicros, elapsedFraction.value))
const forecast = computed(() => headroom(projectedMicros.value, props.limits?.costPerMonthUSD))
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Cost forecast</CardTitle>
<CardDescription>Projected month-end spend, extrapolated from month-to-date usage (UTC calendar month).</CardDescription>
</CardHeader>
<CardContent class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
<div>
<p class="text-muted-foreground">Month to date</p>
<p class="text-lg font-semibold tabular-nums">{{ formatCost(mtdMicros) }}</p>
</div>
<div>
<p class="text-muted-foreground">Projected month-end</p>
<p v-if="projectedMicros === null" class="text-sm text-muted-foreground">not enough data yet</p>
<p
v-else
class="text-lg font-semibold tabular-nums"
:class="forecast.willExceed ? 'text-destructive' : undefined"
:title="forecast.willExceed ? 'Projected to exceed the configured cost/month limit' : undefined"
>
{{ formatCost(projectedMicros) }}
</p>
</div>
<div v-if="forecast.limitMicros !== null">
<p class="text-muted-foreground">Limit</p>
<p class="text-lg font-semibold tabular-nums">{{ formatCost(forecast.limitMicros) }}</p>
</div>
</CardContent>
</Card>
</template>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { faCircleCheck, faCircleXmark, faFileCsv } from '@fortawesome/free-solid-svg-icons'
import { computed, onBeforeUnmount, ref } from 'vue'
import { Button } from '@/components/ui/button'
import { downloadText, type DownloadOutcome } from '@/lib/download'
/**
* CsvExportButton is the one "Export CSV" control every table export in
* this panel uses — F8. Owned by WP-B1 (usage-csv.ts, this component) but
* consumed unmodified by WP-B2's Models tab, which builds its own CSV text
* via lib/usage-csv.ts's modelRankingCsv and passes it through the same
* `build` prop — one export affordance, not a copy per tab (vue.md: "if
* you've written it twice, you owe an abstraction").
*
* `build` is called fresh on every click, never memoized — an export
* always reflects whatever rows are CURRENTLY visible (filtered + sorted)
* at click time, matching the coordinator brief ("exports filtered+sorted
* rows"), never a stale snapshot captured at mount.
*/
const props = defineProps<{
/** The downloaded file's name, e.g. "usage-users.csv". */
filename: string
/** Builds the CSV text (lib/csv.ts's toCsv, via usage-csv.ts) on demand. */
build: () => string
}>()
/**
* exporting guards a rapid double-click from firing two downloads (or two
* copyText fallback prompts) back to back — downloadText (lib/download.ts)
* is async only because its clipboard fallback is, so this window is
* normally imperceptibly short, but a real one regardless.
*/
const exporting = ref(false)
/** P5 review fix: downloadText's own return value used to be discarded entirely — a failed export (or a silent clipboard/selection fallback) looked identical to a successful one. `feedback` holds the most recent outcome; the label/icon below reflect it for FEEDBACK_MS, then the button reverts to its normal resting state. */
const feedback = ref<DownloadOutcome | null>(null)
const FEEDBACK_MS = 2500
let feedbackTimer: ReturnType<typeof setTimeout> | undefined
const FEEDBACK_LABEL: Record<DownloadOutcome, string> = {
downloaded: 'Downloaded',
copied: 'Copied',
selected: 'Selected — press ⌘/Ctrl+C',
failed: 'Export failed',
}
const label = computed(() => (feedback.value ? FEEDBACK_LABEL[feedback.value] : 'Export CSV'))
const isFailure = computed(() => feedback.value === 'failed')
function clearFeedbackAfterDelay(): void {
if (feedbackTimer !== undefined) clearTimeout(feedbackTimer)
feedbackTimer = setTimeout(() => {
feedback.value = null
feedbackTimer = undefined
}, FEEDBACK_MS)
}
async function onClick(): Promise<void> {
if (exporting.value) return
exporting.value = true
try {
feedback.value = await downloadText(props.filename, props.build())
clearFeedbackAfterDelay()
} finally {
exporting.value = false
}
}
onBeforeUnmount(() => {
if (feedbackTimer !== undefined) clearTimeout(feedbackTimer)
})
</script>
<template>
<Button type="button" variant="outline" size="sm" :disabled="exporting" @click="onClick">
<FontAwesomeIcon v-if="feedback === 'failed'" :icon="faCircleXmark" class="size-3.5 text-destructive" aria-hidden="true" />
<FontAwesomeIcon
v-else-if="feedback"
:icon="faCircleCheck"
class="size-3.5 text-chart-requests"
aria-hidden="true"
/>
<FontAwesomeIcon v-else :icon="faFileCsv" class="size-3.5" aria-hidden="true" />
<span :class="isFailure ? 'text-destructive' : undefined">{{ label }}</span>
</Button>
</template>
+23 -4
View File
@@ -12,7 +12,6 @@ import {
TableHead,
TableHeader,
TableRow,
valueUpdater,
} from '@/components/ui/table'
/**
@@ -39,9 +38,24 @@ const props = defineProps<{
rowClass?: (row: TData) => string | undefined
/** Shown in the empty-state row when data is empty. */
emptyMessage?: string
/**
* Optional CONTROLLED sort state (P6, `v-model:sorting`). Omitted (the
* default) keeps every existing call site's original UNCONTROLLED
* behavior unchanged — this component owns `internalSorting` itself and
* a caller has no way to read the current sort. Passed, a caller (e.g.
* UsageView.vue's Users export, which needs to export rows in the
* table's own current sort order — the plan's own "exports
* filtered+sorted rows" requirement) instead owns the ref: every header
* click emits `update:sorting` with the new state rather than mutating
* anything locally here.
*/
sorting?: SortingState
}>()
const sorting = ref<SortingState>([])
const emit = defineEmits<{ 'update:sorting': [value: SortingState] }>()
/** internalSorting is read only when props.sorting is undefined (uncontrolled mode) — see the `sorting` prop's own doc comment above. */
const internalSorting = ref<SortingState>([])
const table = useVueTable({
get data() {
@@ -52,10 +66,15 @@ const table = useVueTable({
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: (updater) => valueUpdater(updater, sorting),
onSortingChange: (updater) => {
const current = props.sorting ?? internalSorting.value
const next = typeof updater === 'function' ? updater(current) : updater
if (props.sorting !== undefined) emit('update:sorting', next)
else internalSorting.value = next
},
state: {
get sorting() {
return sorting.value
return props.sorting ?? internalSorting.value
},
},
})
+137
View File
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted } from 'vue'
import DataTable from '@/components/DataTable.vue'
import SearchInput from '@/components/SearchInput.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { eventsColumns } from '@/lib/events-columns'
import { EVENT_KIND_LABEL, EVENT_KINDS, filterEvents } from '@/lib/events-filter'
import { useEventsStore } from '@/stores/events'
// This component backs the new "Events" tab (App.vue, F3) — GET
// /admin/api/events (admin.go/events.go: adminEventsResponse). Polled only
// while this view is mounted (onMounted/onUnmounted below), unlike the
// Providers/Usage/Targets dashboard poll which runs for the app's whole
// session — see stores/events.ts's own doc comment.
const events = useEventsStore()
const ALL_KINDS = 'all'
// kindFilter/userFilter live on the store, not as local refs (F9,
// hash-state): composables/useHashState.ts reads/writes them directly so
// this filter round-trips through the URL hash. userFilter binds through
// useSearchQuery's own external-ref option, the same way UsageView.vue
// binds nav.usageQuery — see useSearchQuery.ts's own doc comment.
const { kindFilter, userFilter } = storeToRefs(events)
const { normalized: normalizedUserQuery } = useSearchQuery(userFilter)
/** kindSelectValue adapts store's '' ("every kind") to the Select's own ALL_KINDS sentinel — Radix Select items cannot use an empty-string value. */
const kindSelectValue = computed(() => (kindFilter.value === '' ? ALL_KINDS : kindFilter.value))
const filteredEvents = computed(() =>
filterEvents(events.events, {
kind: kindFilter.value,
user: normalizedUserQuery.value,
}),
)
function onKindChange(value: unknown): void {
if (typeof value !== 'string') return
events.setKindFilter(value === ALL_KINDS ? '' : value)
}
const columns = eventsColumns()
/**
* emptyMessage mirrors TargetsView.vue/UsageView.vue's own three-way
* pattern: distinguishes "nothing recorded at all" from "nothing matches
* the active filter".
*
* P9 review fix: gated on events.lastUpdated (null until the first fetch
* settles, stores/events.ts) — this used to read events.events.length
* directly, which is an empty array from the store's OWN initial state,
* before any fetch has even started. That produced "no events recorded"
* for a fraction of a second (or longer, on a slow poll) on every mount,
* indistinguishable from the real "genuinely nothing has ever happened"
* case — a false negative, not a loading state.
*/
const emptyMessage = computed(() => {
if (!events.lastUpdated) return 'loading…'
if (events.events.length === 0) return 'no events recorded'
if (kindFilter.value !== '' || normalizedUserQuery.value) return 'no events match the current filter'
return 'none'
})
/**
* sourceCaption states plainly whether the list below is fleet-wide or
* this-replica-only — the Events view must never present a replica-only
* fallback as if it were the fleet-wide picture.
*
* P9 review fix: gated on events.lastUpdated, mirroring emptyMessage's own
* doc comment above — the store's initial state (source: 'replica',
* replica: '', capacity: 0) otherwise rendered as "This replica only
* (unknown) — Redis is unreachable or unconfigured" before the first
* fetch had even resolved, misreporting a genuine degraded-Redis state
* that had not actually been observed yet.
*/
const sourceCaption = computed(() => {
if (!events.lastUpdated) return 'loading…'
if (events.source === 'redis') return 'Fleet-wide, across every replica'
return `This replica only (${events.replica || 'unknown'}) — Redis is unreachable or unconfigured`
})
onMounted(() => {
events.startPolling()
})
onUnmounted(() => {
events.stopPolling()
})
</script>
<template>
<div class="flex flex-col gap-6">
<Alert v-if="events.degraded" variant="destructive">
<FontAwesomeIcon :icon="faTriangleExclamation" class="size-4" aria-hidden="true" />
<AlertTitle>Fleet-wide event log unavailable</AlertTitle>
<AlertDescription>
Redis is configured but could not be read — showing this replica's own in-memory events only, which may miss
events recorded by other replicas.
</AlertDescription>
</Alert>
<Card>
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Events</CardTitle>
<!-- P9: a loading state until the first fetch settles (events.lastUpdated), instead of showing the store's initial (pre-fetch) source/capacity as if it were real. -->
<CardDescription v-if="!events.lastUpdated">Loading…</CardDescription>
<CardDescription v-else>
{{ sourceCaption }} &mdash; rate limit, budget, and upstream events, newest first (capacity
{{ events.capacity }}).
</CardDescription>
</div>
<div class="flex flex-wrap items-center gap-3">
<SearchInput v-model="userFilter" placeholder="Filter by user or group" class="w-56" />
<Select :model-value="kindSelectValue" @update:model-value="onKindChange">
<SelectTrigger class="w-44" aria-label="Filter by kind">
<SelectValue placeholder="Kind" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="ALL_KINDS">All kinds</SelectItem>
<SelectItem v-for="k in EVENT_KINDS" :key="k" :value="k">{{ EVENT_KIND_LABEL[k] }}</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
<p v-if="events.error" class="mb-3 text-sm text-destructive">{{ events.error }}</p>
<DataTable :columns="columns" :data="filteredEvents" :empty-message="emptyMessage" />
</CardContent>
</Card>
</div>
</template>
+84 -13
View File
@@ -10,6 +10,7 @@ import { computed, h, reactive, watch } from 'vue'
import DataTable from '@/components/DataTable.vue'
import ModelChip from '@/components/ModelChip.vue'
import ProviderRateBadge from '@/components/ProviderRateBadge.vue'
import ScopeLink from '@/components/ScopeLink.vue'
import SearchInput from '@/components/SearchInput.vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
@@ -25,6 +26,7 @@ import { formatAgo, formatContextWindow, formatLatencyMs, formatUntil, refreshDe
import { isModelDegraded } from '@/lib/provider-rate'
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
import { useDashboardStore } from '@/stores/dashboard'
import { useNavStore } from '@/stores/nav'
import type { AdminAliasView, AdminLatencyView, AdminModelMetaView, AdminModelRateView, AdminProvenanceView, AdminProviderView } from '@/types/api'
// This component backs the "Providers" tab (App.vue) — a UI-label rename
@@ -34,6 +36,31 @@ import type { AdminAliasView, AdminLatencyView, AdminModelMetaView, AdminModelRa
// API surface.
const dashboard = useDashboardStore()
const overview = computed(() => dashboard.overview)
const nav = useNavStore()
/**
* goToProviderModels navigates to the Charts Models ranking, pre-filtered
* to this provider's own models (F6, dashboard-plan.md: `nav.goToModels`).
*
* P2 review fix: this used to back a `<span role="link" tabindex="0">`
* nested INSIDE AccordionTrigger's own real `<button>` — invalid per the
* HTML button content model (no descendant may carry a `tabindex`
* attribute, not just no nested button/anchor) and effectively invisible
* to assistive technology (ARIA's `button` role declares "children
* presentational: true", so the nested span was never announced as its
* own link — see ScopeLink.vue's own doc comment for the identical
* finding there). The provider-name label is now a real `<button>`,
* rendered as a SIBLING of AccordionTrigger rather than nested inside it
* (this component's template) — a plain click handler is enough: a click
* on a sibling element never bubbles INTO the trigger's own listener
* (event bubbling only travels through actual ancestors of the click
* target), so the stopPropagation/Enter-only-keydown machinery this used
* to need is gone along with the span it protected — native `<button>`
* keyboard activation (Enter AND Space) is correct here for free.
*/
function goToProviderModels(providerName: string): void {
nav.goToModels(`${providerName}/`)
}
// --- provider expand/collapse (shadcn-vue Accordion) ---
//
@@ -232,23 +259,33 @@ function latencyObservationCount(v: AdminLatencyView): string {
}
/**
* PER_REPLICA_CAVEAT is appended to every latency badge's title (task
* brief's correctness rule 1): this figure comes from the same
* perReplicaCaveat is appended to every latency/provenance badge's title
* (task brief's correctness rule 1): this figure comes from the same
* in-process accumulator the rate-limit rejection counter reads, never
* Redis, so it reflects only whichever of this deployment's several
* Traefik replicas answered the current poll — never a fleet-wide
* average, and a freshly restarted pod shows a short window.
* average, and a freshly restarted pod shows a short window. F4
* (dashboard-plan.md) names the CONCRETE replica (admin.go's g.replica,
* echoed as overview.replica) rather than the abstract warning alone — a
* reader can now tell not just THAT this figure is replica-scoped, but
* WHICH replica it came from. Falls back to the generic "this replica"
* phrasing for a version-skewed server build that has not shipped the
* field yet (undefined, never a fabricated id).
*/
const PER_REPLICA_CAVEAT = 'Per-replica, in-process only — not a fleet-wide average across this deployment’s replicas.'
function perReplicaCaveat(): string {
const replica = overview.value?.replica
const who = replica ? `replica ${replica}` : 'this replica'
return `Per-replica (${who}), in-process only — not a fleet-wide average across this deployment’s replicas.`
}
/** streamingLatencyTitle is the streaming ttfb badge's hover/title detail. */
function streamingLatencyTitle(v: AdminLatencyView): string {
return `Average time to first byte, the load-sensitive reading for streaming traffic. ${latencyObservationCount(v)}. ${PER_REPLICA_CAVEAT}`
return `Average time to first byte, the load-sensitive reading for streaming traffic. ${latencyObservationCount(v)}. ${perReplicaCaveat()}`
}
/** nonStreamingLatencyTitle is the non-streaming avg-duration badge's hover/title detail — explicit that this is NOT a latency/ttfb reading (correctness rule 2). */
function nonStreamingLatencyTitle(v: AdminLatencyView): string {
return `Average total response time, including generation — not a load-sensitive signal like streaming ttfb, since this provider buffers the whole completion before sending anything. ${latencyObservationCount(v)}. ${PER_REPLICA_CAVEAT}`
return `Average total response time, including generation — not a load-sensitive signal like streaming ttfb, since this provider buffers the whole completion before sending anything. ${latencyObservationCount(v)}. ${perReplicaCaveat()}`
}
// --- usage provenance (feat: expose token-accounting provenance) ---
@@ -276,13 +313,13 @@ function unbilledProvenance(p: AdminProviderView): AdminProvenanceView | undefin
/** estimatedProvenanceTitle is the estimated-accounting badge's hover/title detail. */
function estimatedProvenanceTitle(v: AdminProvenanceView): string {
const plural = v.requests === 1 ? '' : 's'
return `${v.requests} non-streaming response${plural} carried no usage from the provider — prompt tokens were estimated from request body size instead (${v.tokens} tokens billed from the estimate), completion billed as zero. ${PER_REPLICA_CAVEAT}`
return `${v.requests} non-streaming response${plural} carried no usage from the provider — prompt tokens were estimated from request body size instead (${v.tokens} tokens billed from the estimate), completion billed as zero. ${perReplicaCaveat()}`
}
/** unbilledProvenanceTitle is the unbilled-accounting badge's hover/title detail — explicit about the budget-exposure implication (task brief's own framing). */
function unbilledProvenanceTitle(v: AdminProvenanceView): string {
const plural = v.requests === 1 ? '' : 's'
return `${v.requests} streaming response${plural} carried no usage from the provider — the request was counted but zero tokens were billed. If this keeps happening, this provider may be serving completions entirely free against every budget. ${PER_REPLICA_CAVEAT}`
return `${v.requests} streaming response${plural} carried no usage from the provider — the request was counted but zero tokens were billed. If this keeps happening, this provider may be serving completions entirely free against every budget. ${perReplicaCaveat()}`
}
// --- model aliases (sortable DataTable, operator directive) ---
@@ -331,6 +368,7 @@ const aliasEmptyMessage = computed(() =>
:icon="overview?.redis.configured ? faCircleCheck : faCircleXmark"
:class="overview?.redis.configured ? 'text-chart-requests' : 'text-muted-foreground'"
class="size-3.5"
aria-hidden="true"
/>
Redis
</CardTitle>
@@ -338,7 +376,7 @@ const aliasEmptyMessage = computed(() =>
<CardContent class="flex flex-col gap-1 text-sm">
<p class="font-medium">{{ overview?.redis.configured ? 'Configured' : 'Not configured' }}</p>
<p v-if="overview?.redis.lastErr" class="flex items-start gap-1.5 text-destructive">
<FontAwesomeIcon :icon="faTriangleExclamation" class="mt-0.5 size-3.5 shrink-0" />
<FontAwesomeIcon :icon="faTriangleExclamation" class="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
<span>{{ overview.redis.lastErr }}{{ formatAgo(overview.redis.lastErrAt) }}</span>
</p>
</CardContent>
@@ -351,6 +389,7 @@ const aliasEmptyMessage = computed(() =>
:icon="overview?.cache.enabled ? faCircleCheck : faCircleXmark"
:class="overview?.cache.enabled ? 'text-chart-requests' : 'text-muted-foreground'"
class="size-3.5"
aria-hidden="true"
/>
Response cache
</CardTitle>
@@ -368,6 +407,7 @@ const aliasEmptyMessage = computed(() =>
:icon="overview?.retry.enabled ? faCircleCheck : faCircleXmark"
:class="overview?.retry.enabled ? 'text-chart-requests' : 'text-muted-foreground'"
class="size-3.5"
aria-hidden="true"
/>
Retry
</CardTitle>
@@ -398,11 +438,37 @@ const aliasEmptyMessage = computed(() =>
>
no providers match &quot;{{ modelQuery }}&quot;
</p>
<Accordion v-else v-model="expandedProviderValues" type="multiple" class="rounded-md border px-3">
<template v-else>
<!-- F4 (dashboard-plan.md): latency/provenance badges below are per-replica, in-process figures — this caption names which replica this poll answered from, up front, instead of leaving it to each badge's own hover title. -->
<p class="mb-2 text-xs text-muted-foreground">{{ perReplicaCaveat() }}</p>
<Accordion v-model="expandedProviderValues" type="multiple" class="rounded-md border px-3">
<AccordionItem v-for="p in filteredProviders" :key="p.name" :value="p.name">
<AccordionTrigger>
<!--
P2 review fix: the provider-name link moved OUTSIDE
AccordionTrigger — see goToProviderModels' own doc comment
above for why. `py-2.5` matches AccordionTrigger.vue's own
vertical padding so the two line up in this row.
`header-class="flex-1"` fills the AccordionHeader (<h3>) that
wraps the trigger button, since AccordionTrigger's own
`class` prop lands on the inner button, not the header — see
AccordionTrigger.vue's doc comment. Without it the header
shrinks to content width, so the chevron drifts left of the
row's right edge and the empty space to its right stops
being clickable. The sr-only span gives the toggle button an
accessible name that names the provider, since the visible
text beside it is just badges and counts.
-->
<div class="flex items-center gap-2">
<button
type="button"
class="shrink-0 cursor-pointer rounded-sm py-2.5 font-medium hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
:aria-label="`View ${p.name}'s models in Charts`"
:title="`View ${p.name}'s models in Charts`"
@click="goToProviderModels(p.name)"
>{{ p.name }}</button>
<AccordionTrigger header-class="flex-1">
<span class="sr-only">Provider {{ p.name }} details</span>
<span class="flex flex-1 flex-wrap items-center gap-x-3 gap-y-1 pr-2 text-left">
<span class="font-medium">{{ p.name }}</span>
<Badge as="span" variant="secondary" class="font-normal">{{ p.type }}</Badge>
<span class="text-xs text-muted-foreground tabular-nums">{{ p.modelCount }} models</span>
<ProviderRateBadge
@@ -488,7 +554,8 @@ const aliasEmptyMessage = computed(() =>
/>
<span class="text-xs text-muted-foreground">{{ refreshLabel(p.discoveryEnabled, p.lastRefresh) }}</span>
</span>
</AccordionTrigger>
</AccordionTrigger>
</div>
<AccordionContent>
<dl class="mb-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm sm:grid-cols-3">
<div>
@@ -516,6 +583,9 @@ const aliasEmptyMessage = computed(() =>
:input-per-m-tok-usd="modelMetaFor(p, m).inputPerMTokUsd"
:output-per-m-tok-usd="modelMetaFor(p, m).outputPerMTokUsd"
/>
<!-- ScopeLink (F6): jumps to Charts, scoped to this exact model — renders here as a real <button> inside AccordionContent (not AccordionTrigger), same as the provider-name button above (P2 review fix moved both out of the trigger). -->
<ScopeLink :label="routableModelId(p.name, m)" :scope="`model:${routableModelId(p.name, m)}`" />
<!--
Context chip, visible, muted (feature v0.23) — only
when known. Cost stays hover-only on ModelChip itself
@@ -550,6 +620,7 @@ const aliasEmptyMessage = computed(() =>
</AccordionContent>
</AccordionItem>
</Accordion>
</template>
</CardContent>
</Card>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { faChartLine } from '@fortawesome/free-solid-svg-icons'
import { useNavStore } from '@/stores/nav'
/**
* ScopeLink (F6) is the one "jump to this scope's Charts view" control in
* this panel: usage-columns.ts's id cell (Users/Groups tables and the
* nested Members table), UsageView.vue's Groups accordion header, and
* WP-B2's ProvidersView.vue per-model chips all render one of these
* instead of each hand-rolling their own click-through-to-Charts wiring.
*
* A real `<button type="button">` (P2 review fix). This used to be a
* `<span role="link" tabindex="0">`, justified at the time by the Groups
* accordion trigger use site nesting it INSIDE shadcn-vue's
* AccordionTrigger — itself a real `<button>` (reka-ui's own
* AccordionTrigger primitive). That justification was wrong on its own
* terms: the HTML button content model forbids ANY descendant carrying a
* `tabindex` attribute, not just a nested button/anchor, and ARIA's
* `button` role additionally declares "children presentational: true", so
* assistive technology flattened the inner span and never exposed it as
* its own link at all — axe flags this as `nested-interactive`. Reka's
* AccordionTrigger also only listens for `click` (not `keydown`), so
* `stopPropagation()` on click and Enter suppressed the accordion's own
* toggle, but Space on the focused span did nothing and fell through to
* page scroll instead of activating anything.
*
* The actual fix is structural, not a markup trick: every call site now
* renders this component as a SIBLING of whatever it used to nest inside
* (UsageView.vue's Groups header, ProvidersView.vue's provider-name
* header — see both templates), never a descendant of another interactive
* element, so a real `<button>` is valid there. No `role` override: a
* plain button's implicit role is already correct here (activating it
* performs an action — a programmatic Pinia-store navigation, not a
* traditional `href` follow — which is exactly what a native `<button>`,
* not `<a>`, is for; WAI-ARIA's first rule of ARIA use is not to override
* a native element's semantics without a reason, and there is none left
* here). `stopPropagation` on click stays — harmless everywhere (a table
* cell, ProvidersView's per-model chips) and still correct defensively at
* the Groups/Providers headers, where this button sits next to, not
* inside, the accordion's own trigger.
*/
const props = defineProps<{
/** The visible + accessible label, e.g. a user id, group id, or model id. */
label: string
/** The scope string useNavStore.goToCharts expects — "total" | "user:{id}" | "group:{id}" | "model:{id}" (history.ts's own convention). */
scope: string
}>()
const nav = useNavStore()
function onClick(event: MouseEvent): void {
event.stopPropagation()
nav.goToCharts(props.scope)
}
</script>
<template>
<button
type="button"
:aria-label="`View ${label} in Charts`"
:title="`View ${label} in Charts`"
class="inline-flex cursor-pointer items-center gap-1 rounded-sm text-inherit hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
@click="onClick"
>
<span>{{ label }}</span>
<FontAwesomeIcon :icon="faChartLine" class="size-3" aria-hidden="true" />
</button>
</template>
+1 -1
View File
@@ -72,7 +72,7 @@ function onClear(): void {
class="absolute top-1/2 right-1 -translate-y-1/2 text-muted-foreground hover:text-foreground"
@click="onClear"
>
<FontAwesomeIcon :icon="faXmark" class="size-3.5" />
<FontAwesomeIcon :icon="faXmark" class="size-3.5" aria-hidden="true" />
</Button>
</div>
</template>
+73
View File
@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@/lib/utils'
import { ratioTier } from '@/lib/usage-bars'
/**
* UsageBar renders one BudgetRatio (lib/usage-bars.ts) as a compact
* horizontal meter — F1, usage-vs-limit bars. A real `role="meter"`
* (not a `<progress>` element: a meter reports a measurement against a
* range, which is exactly a used/limit reading, where `<progress>`
* implies a task moving toward completion) carries the accessible
* reading via `aria-valuenow`/`aria-valuemin`/`aria-valuemax`/
* `aria-valuetext`, so a screen reader announces the same number a
* sighted reader sees in the fill width and the `title` tooltip.
*
* Used two places (usage-columns.ts's numeric cells, UsageView.vue's
* group detail grid) — both pass a fully-computed BudgetRatio plus the
* caller's own human-readable `valueText`, so this component has no
* formatting opinion of its own (formatCost/formatExactInt stay the
* caller's job, same "logic lives in lib/, not the component" split
* every other shared component in this panel follows).
*/
const props = defineProps<{
/** used / limit — may exceed 1 (a scope's usage window can roll past its limit before the next request is rejected). */
ratio: number
/** The accessible name for this meter, e.g. "req/day" or "cost/month". */
label: string
/** The human-readable "used of limit" text — both the `title` tooltip and `aria-valuetext`, e.g. "500 / 1,000" or "$1.20 / $5.00". */
valueText: string
class?: HTMLAttributes['class']
}>()
const tier = computed(() => ratioTier(props.ratio))
/**
* widthPct clamps the visible fill (and, via valueNow below, the
* announced aria-valuenow) to 100% — a ratio above 1.0 (over the limit)
* still reads as a full bar, with the RED tier and valueText's own real
* "used / limit" numbers carrying the "how far over" detail instead of an
* overflowing bar or an aria-valuenow exceeding its own declared
* aria-valuemax (WAI-ARIA meter role: valuenow must stay within
* valuemin/valuemax).
*/
const widthPct = computed(() => Math.min(100, Math.max(0, props.ratio * 100)))
const valueNow = computed(() => Math.round(widthPct.value))
const TIER_FILL_CLASS: Record<ReturnType<typeof ratioTier>, string> = {
normal: 'bg-primary',
amber: 'bg-bar-amber',
red: 'bg-destructive',
}
</script>
<template>
<span
role="meter"
:aria-label="label"
:aria-valuenow="valueNow"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuetext="valueText"
:title="valueText"
:class="cn('relative inline-block h-1.5 w-14 shrink-0 overflow-hidden rounded-full bg-muted align-middle', props.class)"
>
<span
class="absolute inset-y-0 left-0 rounded-full"
:class="TIER_FILL_CLASS[tier]"
:style="{ width: `${widthPct}%` }"
/>
</span>
</template>
+10 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { SortingState } from '@tanstack/vue-table'
import { computed } from 'vue'
import DataTable from '@/components/DataTable.vue'
@@ -21,9 +22,15 @@ const props = defineProps<{
secondaryValue: (entry: AdminUsageEntryView) => string
/** Shown in the empty-state row when entries is empty — e.g. a filtered-search "no X match ..." message. Falls through to DataTable's own 'none' default when omitted. */
emptyMessage?: string
/** P1: the shared reactive clock (composables/useNow.ts) — threaded into usageColumns() below so this table's own costMonthProj column recomputes as the clock ticks, instead of freezing at whatever instant this computed first ran. */
now: Date
/** P6: optional controlled sort state, forwarded to DataTable's own `v-model:sorting` (see that component's identical prop doc comment) — omitted everywhere except UsageView.vue's top-level Users table, whose export must reflect the table's current sort. */
sorting?: SortingState
}>()
const columns = computed(() => usageColumns(props.idLabel, props.secondaryColumnLabel, props.secondaryValue))
const emit = defineEmits<{ 'update:sorting': [value: SortingState] }>()
const columns = computed(() => usageColumns(props.idLabel, props.secondaryColumnLabel, props.secondaryValue, props.now))
</script>
<template>
@@ -32,5 +39,7 @@ const columns = computed(() => usageColumns(props.idLabel, props.secondaryColumn
:data="entries"
:empty-message="emptyMessage"
:row-class="(entry) => (entry.storeDown ? 'bg-destructive/10' : undefined)"
:sorting="sorting"
@update:sorting="(v) => emit('update:sorting', v)"
/>
</template>
+209 -21
View File
@@ -1,21 +1,31 @@
<script setup lang="ts">
import type { SortingState } from '@tanstack/vue-table'
import { getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
import { computed, reactive, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import CompactNumber from '@/components/CompactNumber.vue'
import CostForecastCard from '@/components/CostForecastCard.vue'
import CsvExportButton from '@/components/CsvExportButton.vue'
import ModelChip from '@/components/ModelChip.vue'
import ScopeLink from '@/components/ScopeLink.vue'
import SearchInput from '@/components/SearchInput.vue'
import SortHeaderButton from '@/components/SortHeaderButton.vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { valueUpdater } from '@/components/ui/table'
import UsageBar from '@/components/UsageBar.vue'
import UsageTable from '@/components/UsageTable.vue'
import { useNow } from '@/composables/useNow'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { formatCost, formatLimits } from '@/lib/format'
import { useNavStore } from '@/stores/nav'
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
import { formatCost, formatExactInt, formatLimits } from '@/lib/format'
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
import { BUDGET_RATIO_LABEL, type BudgetRatio, budgetRatios } from '@/lib/usage-bars'
import { usageColumns } from '@/lib/usage-columns'
import { usageCsv } from '@/lib/usage-csv'
import { groupMatches, groupNameMatches, matchingMembersOfGroup, membersOfGroup, userMatches } from '@/lib/usage-search'
import { useDashboardStore } from '@/stores/dashboard'
import type { AdminUsageEntryView } from '@/types/api'
@@ -23,6 +33,17 @@ import type { AdminUsageEntryView } from '@/types/api'
const dashboard = useDashboardStore()
const usage = computed(() => dashboard.usage)
// The one shared reactive clock every time-dependent read on this tab uses
// (P1, composables/useNow.ts's own doc comment) — CostForecastCard's own
// elapsedFraction, usageColumns()'s `now` parameter (both the real Users/
// Members <table>s below and the headless Groups toolbar's groupColumns),
// and groupCostProjection all read THIS ref, never their own `new Date()`,
// so a long-open tab's cost projections advance together instead of
// freezing at whatever instant each one happened to first run.
const { now, start: startClock, stop: stopClock } = useNow()
onMounted(startClock)
onUnmounted(stopClock)
/** memberCounts maps a group name to its member count — only the Overview response carries it. */
const memberCounts = computed<Record<string, number>>(() => {
const out: Record<string, number> = {}
@@ -45,7 +66,9 @@ function memberCountOf(entry: AdminUsageEntryView): string {
// Matching logic lives in lib/usage-search.ts, which ChartsView.vue's own
// scope-picker filter calls directly too (groupMatches, userMatches) — no
// second copy of "does this user/group match?") ---
const { query: userQuery, normalized: normalizedQuery, hasQuery } = useSearchQuery()
// Bound to nav.usageQuery so the search text round-trips through the URL hash (#usage?q=…, lib/hash-state.ts).
const { usageQuery } = storeToRefs(useNavStore())
const { query: userQuery, normalized: normalizedQuery, hasQuery } = useSearchQuery(usageQuery)
/**
* filteredGroups is every group matching the query — own name OR a
@@ -158,14 +181,14 @@ const usersEmptyMessage = computed(() =>
// SortHeaderButton DataTable.vue's real <th>s use (same icons, same
// toggleSorting call, same accessible name), just laid out as a toolbar
// instead of <th>s, because there is no <table> here to put a <th> in.
const groupColumns = usageColumns('Name', 'Members', memberCountOf)
const groupColumns = computed(() => usageColumns('Name', 'Members', memberCountOf, now.value))
const groupSorting = ref<SortingState>([])
const groupsTable = useVueTable({
get data() {
return filteredGroups.value
},
get columns() {
return groupColumns
return groupColumns.value
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
@@ -257,6 +280,100 @@ const GLOB_HINT = 'Glob pattern — matches like a shell wildcard (*, ?, [...])'
function isGlobPattern(entry: string): boolean {
return /[*?[]/.test(entry)
}
// --- F1 usage-vs-limit bars (group detail grid) ---
//
// usage-columns.ts's own numericColumn already renders a bar beside each
// TABLE cell that has a configured limit (Users table, and — reused — the
// Members table inside an expanded group). A group's own accordion detail
// grid (the dl/dd block below, distinct from its nested Members table) has
// no per-cell numeric columns at all, so it renders budgetRatios directly
// as one compact row of labeled bars instead — the coordinator brief's own
// phrasing ("Group detail grid ... renders bars from budgetRatios").
// BUDGET_RATIO_LABEL (P10/P11 review fix): imported from lib/usage-bars.ts
// — this used to be a second, independently-declared copy here (BUDGET_LABEL)
// with slightly different text ("tok/day" here vs usage-columns.ts's own
// bar label), the exact DRY drift the coordinator's review flagged. The one
// shared definition also spells out "tokens (in+out)/day" (P10) for the
// tokDay/tokMonth ids — accurate for a screen-reader user, who has no OTHER
// column beside this compact grid's own labeled bar to infer the combined-
// total measurement from.
/** budgetValueText renders one BudgetRatio's "used / limit" detail text (UsageBar's aria-valuetext/title) — cost ids in dollars (formatCost, the micro-USD unit budgetRatios already computed them in), every other id as a plain thousands-separated count. */
function budgetValueText(ratio: BudgetRatio): string {
if (ratio.id.startsWith('cost')) return `${formatCost(ratio.used)} / ${formatCost(ratio.limit)}`
return `${formatExactInt(ratio.used)} / ${formatExactInt(ratio.limit)}`
}
/**
* groupCostProjection (F7) mirrors usage-columns.ts's own costMonthProjColumn
* math for one group row — the coordinator brief's own phrasing ("also in
* group grid"): the group detail dl/dd block is not a DataTable column, so
* it cannot reuse that column's ColumnDef cell renderer directly, but the
* underlying lib/forecast.ts math (monthProgress/projectMonthEnd/headroom)
* is identical. `now` is the shared clock (useNow, above) — the SAME
* instant every other time-dependent read on this tab uses — not a fresh
* `new Date()` read per call, so this grid's own projection never disagrees
* with CostForecastCard's or a column's mid-render.
*/
function groupCostProjection(entry: AdminUsageEntryView, now: Date): { text: string; willExceed: boolean } {
const elapsedFraction = monthProgress(now)
const projected = projectMonthEnd(entry.costPerMonthMicroUsd, elapsedFraction)
if (projected === null) return { text: 'not enough data yet', willExceed: false }
return { text: formatCost(projected), willExceed: headroom(projected, entry.limits?.costPerMonthUSD).willExceed }
}
// --- F8 CSV export ---
//
// Each button exports whatever rows are CURRENTLY visible: Groups export
// in the Groups toolbar's own sort order (sortedGroupRows, already
// computed below for the headless sort toolbar); Users export in the
// Users <table>'s own CURRENT column sort (P6 review fix — this used to
// export in filtered order alone, a self-declared deviation from the plan's
// "exports filtered+sorted rows"). The Users DataTable's sort state is
// LIFTED UP here via DataTable.vue's `v-model:sorting` (that component's
// own doc comment) — usersSorting below is bound two ways: down into the
// rendered UsageTable (template) so a header click writes back here, and
// into a second, headless useVueTable instance (usersTable) that applies
// the identical sort to filteredUsers so usersCsv can read it back in the
// same order the reader is actually looking at — the same "one sort state,
// two consumers" shape sortedGroupRows/groupsTable already used for Groups.
const usersColumns = computed(() => usageColumns('Name', 'Group', groupNameOf, now.value))
const usersSorting = ref<SortingState>([])
const usersTable = useVueTable({
get data() {
return filteredUsers.value
},
get columns() {
return usersColumns.value
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: (updater) => valueUpdater(updater, usersSorting),
state: {
get sorting() {
return usersSorting.value
},
},
})
function usersCsv(): string {
return usageCsv(
usersTable.getRowModel().rows.map((row) => row.original),
'users',
groupNameOf,
now.value,
)
}
function groupsCsv(): string {
return usageCsv(
sortedGroupRows.value.map((row) => row.original),
'groups',
memberCountOf,
now.value,
)
}
</script>
<template>
@@ -268,11 +385,18 @@ function isGlobPattern(entry: string): boolean {
<CardTitle>Total</CardTitle>
<CardDescription>Usage summed across every user and group combined.</CardDescription>
</CardHeader>
<CardContent class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<CardContent class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-5">
<div>
<p class="text-muted-foreground">Requests/day</p>
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="usage.total.requestsPerDay" /></p>
</div>
<div>
<!-- F4: fleet-wide requests denied by a limit check today (types/api.ts's own doc comment) — destructive tint once positive, mirroring usage-columns.ts's own rejDay column convention. -->
<p class="text-muted-foreground">Rejected/day</p>
<p class="text-lg font-semibold tabular-nums" :class="usage.total.rejectionsPerDay > 0 ? 'text-destructive' : undefined">
<CompactNumber :value="usage.total.rejectionsPerDay" />
</p>
</div>
<div>
<p class="text-muted-foreground">Tokens in/day</p>
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="usage.total.tokensInPerDay" /></p>
@@ -288,10 +412,18 @@ function isGlobPattern(entry: string): boolean {
</CardContent>
</Card>
<!-- F7: fleet-wide cost forecast, directly under the Total card (coordinator brief). -->
<CostForecastCard v-if="usage" :mtd-micros="usage.total.costPerMonthMicroUsd" :limits="usage.total.limits" :now="now" />
<Card>
<CardHeader>
<CardTitle>Groups</CardTitle>
<CardDescription>Expand a group to see its member users' own usage.</CardDescription>
<div class="flex items-start justify-between gap-2">
<div>
<CardTitle>Groups</CardTitle>
<CardDescription>Expand a group to see its member users' own usage.</CardDescription>
</div>
<CsvExportButton filename="usage-groups.csv" :build="groupsCsv" />
</div>
</CardHeader>
<CardContent>
<div
@@ -303,19 +435,40 @@ function isGlobPattern(entry: string): boolean {
<Accordion v-if="sortedGroupRows.length" v-model="expandedGroupValues" type="multiple" class="rounded-md border px-3">
<AccordionItem v-for="row in sortedGroupRows" :key="row.original.id" :value="row.original.id">
<AccordionTrigger>
<span class="flex flex-1 flex-wrap items-center gap-x-4 gap-y-1 pr-2 text-left">
<span class="font-medium">{{ row.original.id }}</span>
<span class="text-xs text-muted-foreground tabular-nums">{{ memberCountOf(row.original) || '0' }} members</span>
<span class="text-xs text-muted-foreground tabular-nums">
<template v-if="row.original.storeDown">?</template>
<CompactNumber v-else :value="row.original.requestsPerDay" /> req/day
<!--
P2 review fix: ScopeLink moved OUTSIDE AccordionTrigger — a
real interactive element can never be a valid descendant of
another interactive element (AccordionTrigger's own <button>,
reka-ui), so it is now a sibling in this header row instead
of nested inside the trigger's own slot. `py-2.5` matches
AccordionTrigger.vue's own vertical padding so the two line
up, keeping the same visual row the nested version had.
`header-class="flex-1"` fills the AccordionHeader (<h3>) that
wraps the trigger button, since AccordionTrigger's own
`class` prop lands on the inner button, not the header — see
AccordionTrigger.vue's doc comment. Without it the header
shrinks to content width, so the chevron drifts left of the
row's right edge and the empty space to its right stops
being clickable. The sr-only span gives the toggle button an
accessible name that names the group, since the visible text
beside it is just counts.
-->
<div class="flex items-center gap-2">
<ScopeLink class="shrink-0 py-2.5 font-medium" :label="row.original.id" :scope="`group:${row.original.id}`" />
<AccordionTrigger header-class="flex-1">
<span class="sr-only">Group {{ row.original.id }} details</span>
<span class="flex flex-1 flex-wrap items-center gap-x-4 gap-y-1 pr-2 text-left">
<span class="text-xs text-muted-foreground tabular-nums">{{ memberCountOf(row.original) || '0' }} members</span>
<span class="text-xs text-muted-foreground tabular-nums">
<template v-if="row.original.storeDown">?</template>
<CompactNumber v-else :value="row.original.requestsPerDay" /> req/day
</span>
<span class="text-xs text-muted-foreground tabular-nums">
{{ row.original.storeDown ? '?' : formatCost(row.original.costPerDayMicroUsd) }}/day
</span>
</span>
<span class="text-xs text-muted-foreground tabular-nums">
{{ row.original.storeDown ? '?' : formatCost(row.original.costPerDayMicroUsd) }}/day
</span>
</span>
</AccordionTrigger>
</AccordionTrigger>
</div>
<AccordionContent>
<!--
Access block (group-access-display task): the group's
@@ -404,6 +557,13 @@ function isGlobPattern(entry: string): boolean {
<CompactNumber v-else :value="row.original.requestsPerMinute" />
</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">rejected/day</dt>
<dd class="tabular-nums" :class="!row.original.storeDown && row.original.rejectionsPerDay > 0 ? 'text-destructive' : undefined">
<template v-if="row.original.storeDown">?</template>
<CompactNumber v-else :value="row.original.rejectionsPerDay" />
</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">tokIn/day</dt>
<dd class="tabular-nums">
@@ -436,13 +596,36 @@ function isGlobPattern(entry: string): boolean {
<dt class="text-xs text-muted-foreground">cost/month</dt>
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : formatCost(row.original.costPerMonthMicroUsd) }}</dd>
</div>
<div>
<!-- F7: projected month-end cost, extrapolated from this group's own month-to-date spend (mirrors usage-columns.ts's costMonthProj column). -->
<dt class="text-xs text-muted-foreground">cost/month (proj.)</dt>
<dd
class="tabular-nums"
:class="!row.original.storeDown && groupCostProjection(row.original, now).willExceed ? 'text-destructive' : undefined"
:title="!row.original.storeDown && groupCostProjection(row.original, now).willExceed ? 'Projected to exceed the configured cost/month limit' : undefined"
>
{{ row.original.storeDown ? '?' : groupCostProjection(row.original, now).text }}
</dd>
</div>
</dl>
<!-- F1: usage-vs-limit bars, one per configured limit — budgetRatios (lib/usage-bars.ts) returns nothing for an unlimited group or a storeDown one, so this whole row is simply absent then. -->
<div v-if="budgetRatios(row.original).length" class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1.5">
<span
v-for="ratio in budgetRatios(row.original)"
:key="ratio.id"
class="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
>
{{ BUDGET_RATIO_LABEL[ratio.id] }}
<UsageBar :ratio="ratio.ratio" :label="BUDGET_RATIO_LABEL[ratio.id]" :value-text="budgetValueText(ratio)" />
</span>
</div>
<p class="mb-1.5 text-xs font-medium text-muted-foreground">Members</p>
<UsageTable
id-label="Name"
secondary-column-label="Group"
:entries="visibleMembers(row.original)"
:secondary-value="groupNameOf"
:now="now"
/>
</AccordionContent>
</AccordionItem>
@@ -453,7 +636,10 @@ function isGlobPattern(entry: string): boolean {
<Card>
<CardHeader>
<CardTitle>Users</CardTitle>
<div class="flex items-start justify-between gap-2">
<CardTitle>Users</CardTitle>
<CsvExportButton filename="usage-users.csv" :build="usersCsv" />
</div>
</CardHeader>
<CardContent>
<UsageTable
@@ -462,6 +648,8 @@ function isGlobPattern(entry: string): boolean {
:entries="filteredUsers"
:secondary-value="groupNameOf"
:empty-message="usersEmptyMessage"
:now="now"
v-model:sorting="usersSorting"
/>
</CardContent>
</Card>
@@ -10,13 +10,25 @@ import {
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
const props = defineProps<AccordionTriggerProps & {
class?: HTMLAttributes['class']
/**
* Class applied to the AccordionHeader (the <h3> wrapper), not the
* trigger button. Reka-ui's AccordionTrigger declares its own `class`
* prop, so a `class` passed to this component lands on the inner
* button, never on the header — pass `flex-1` here (not to `class`)
* when the trigger sits next to a sibling in a flex row, so the header
* (and the button it wraps) fills the remaining row width instead of
* shrinking to content width.
*/
headerClass?: HTMLAttributes['class']
}>()
const delegatedProps = reactiveOmit(props, 'class')
const delegatedProps = reactiveOmit(props, 'class', 'headerClass')
</script>
<template>
<AccordionHeader class="flex">
<AccordionHeader :class="cn('flex', props.headerClass)">
<AccordionTrigger
data-slot="accordion-trigger"
v-bind="delegatedProps"
+9
View File
@@ -10,6 +10,15 @@ export const alertVariants = cva('grid gap-0.5 rounded-lg border px-2.5 py-2 tex
variant: {
default: 'bg-card text-card-foreground',
destructive: 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
/**
* warn: a non-destructive "worth a look, not broken" tier —
* reuses the panel's shared --status-warn token (main.css,
* already the Providers tab's degraded badge and the usage-bar
* amber threshold) instead of `destructive`'s red, which implies
* a hard failure. Same structure as `destructive` above, only the
* color changes.
*/
warn: 'text-status-warn bg-card *:data-[slot=alert-description]:text-status-warn/90 *:[svg]:text-current',
},
},
defaultVariants: {