mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
feat(webui): make the dashboard responsive and add loading and empty states
Every tab row scrolls sideways instead of being clipped by its card. On phones this hid tabs, such as the Config change helper's last forms at 360-390px. The login button has a larger touch target, and long names are truncated with a tooltip. Loading and empty states: - Skeletons show only while a selection has no data yet, never over data already on screen during a refresh. - Empty states name the next step to take. - Error states offer a Retry button that shows progress, and each card keeps its own error. - Toasts confirm copies and key generation, and report background failures once per streak through polite and assertive live regions. Other changes: - The access matrix switches between Models, MCP servers and Agents, and says "none configured" only once its data has loaded. - Home ranks the top 5 users by cost, requests or tokens. It requests only the needed metrics and warns before a range exceeds the server's key cap. - MCP and agent deep links accept a bare target name. admin_assets_gen.go is regenerated. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
+72
-66
File diff suppressed because one or more lines are too long
+40
-12
@@ -8,7 +8,9 @@ import AuthGate from '@/components/AuthGate.vue'
|
||||
import ConfigWarningsBanner from '@/components/ConfigWarningsBanner.vue'
|
||||
import GlobalFilterBar from '@/components/GlobalFilterBar.vue'
|
||||
import SidebarNav from '@/components/SidebarNav.vue'
|
||||
import ToastViewport from '@/components/ToastViewport.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useHashState } from '@/composables/useHashState'
|
||||
import type { PageId } from '@/lib/pages'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -56,20 +58,25 @@ const currentPage = computed<Component>(() => PAGES[nav.page])
|
||||
// statusText reads dashboard.error/lastUpdated unconditionally — it is
|
||||
// correct only while the template's own `v-if="auth.isAuthenticated"`
|
||||
// below keeps it off-screen otherwise, so a first load with no stored
|
||||
// key never shows a stale "refresh failed"/"loading..." line under the
|
||||
// auth gate. Appended only on a genuinely successful poll —
|
||||
// dashboard.overview.replica reflects WHICH replica answered that poll,
|
||||
// so it must never be shown alongside a stale value during a failed or
|
||||
// still-loading refresh.
|
||||
// key never shows a stale "refresh failed" line under the auth gate.
|
||||
// Appended only on a genuinely successful poll — dashboard.overview.
|
||||
// replica reflects WHICH replica answered that poll, so it must never be
|
||||
// shown alongside a stale value during a failed or still-loading refresh.
|
||||
// The unreachable 'loading...' fallback (verify-ui-states.md #5 fix) was
|
||||
// dead code: isInitialStatusLoad below covers exactly
|
||||
// `!dashboard.error && !dashboard.lastUpdated`, the ONLY state in which
|
||||
// neither of this function's two `if` branches fires, and the template
|
||||
// renders the Skeleton for that state instead of ever reading statusText.
|
||||
const statusText = computed<string>(() => {
|
||||
if (dashboard.error) return `refresh failed: ${dashboard.error}`
|
||||
if (dashboard.lastUpdated) {
|
||||
const replica = dashboard.overview?.replica
|
||||
return `last updated ${dashboard.lastUpdated.toLocaleTimeString()}${replica ? ` · replica ${replica}` : ''}`
|
||||
}
|
||||
return 'loading...'
|
||||
const replica = dashboard.overview?.replica
|
||||
const updated = dashboard.lastUpdated?.toLocaleTimeString() ?? ''
|
||||
return `last updated ${updated}${replica ? ` · replica ${replica}` : ''}`
|
||||
})
|
||||
|
||||
/** isInitialStatusLoad gates the header status line's skeleton — the FIRST dashboard poll only (states-plan.md item 1: a skeleton only when there is no data yet, never on a background refresh — dashboard.lastUpdated stays set through every later poll, success or failure). */
|
||||
const isInitialStatusLoad = computed<boolean>(() => !dashboard.error && !dashboard.lastUpdated)
|
||||
|
||||
onMounted(() => {
|
||||
dashboard.startPolling()
|
||||
})
|
||||
@@ -93,9 +100,29 @@ onMounted(() => {
|
||||
<FontAwesomeIcon :icon="faGithub" class="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<!--
|
||||
No role="status" here (verify-ui-states.md #5 fix) — this line
|
||||
updates every 5s (dashboard.ts's POLL_MS), and a live region
|
||||
re-announces its FULL text on every single change; a screen
|
||||
reader would have read "last updated HH:MM:SS" once every 5
|
||||
seconds for as long as the session stayed open. A background
|
||||
refresh FAILURE (the one change actually worth announcing) is
|
||||
already a "meaningful change" a screen reader hears from
|
||||
elsewhere: stores/dashboard.ts's own toast (states-plan.md item 3,
|
||||
"background refresh failures when data is already shown") fires
|
||||
exactly once per failure streak, through ToastViewport's own
|
||||
assertive live region — a second, duplicate announcement here
|
||||
would only repeat it.
|
||||
-->
|
||||
<p v-if="auth.isAuthenticated" class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<FontAwesomeIcon v-if="dashboard.error" :icon="faCircleExclamation" class="size-3.5 text-destructive" aria-hidden="true" />
|
||||
<span :class="dashboard.error ? 'text-destructive' : undefined">{{ statusText }}</span>
|
||||
<template v-if="isInitialStatusLoad">
|
||||
<Skeleton class="h-4 w-40" />
|
||||
<span class="sr-only">Loading dashboard status…</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<FontAwesomeIcon v-if="dashboard.error" :icon="faCircleExclamation" class="size-3.5 text-destructive" aria-hidden="true" />
|
||||
<span :class="dashboard.error ? 'text-destructive' : undefined">{{ statusText }}</span>
|
||||
</template>
|
||||
</p>
|
||||
<GlobalFilterBar v-if="auth.isAuthenticated" />
|
||||
</header>
|
||||
@@ -116,4 +143,5 @@ onMounted(() => {
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<ToastViewport />
|
||||
</template>
|
||||
|
||||
@@ -2,24 +2,41 @@
|
||||
import { faCheck, faTriangleExclamation, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import SkeletonTable from '@/components/SkeletonTable.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { buildAccessMatrix } from '@/lib/access-matrix'
|
||||
import type { AccessCell } from '@/lib/access-matrix'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { buildAccessMatrix, MATRIX_KIND_LABEL, MATRIX_KINDS, matrixColumnsState } from '@/lib/access-matrix'
|
||||
import type { AccessCell, MatrixKind } from '@/lib/access-matrix'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
import { useConsumersStore } from '@/stores/consumers'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
|
||||
/**
|
||||
* AccessMatrix (redesign-plan.md section 3.4) is the Consumers page's
|
||||
* `view=matrix` alternative to ConsumerDirectory: rows are groups, columns
|
||||
* are every configured provider (allowed check + "n/m models", from GET
|
||||
* /admin/api/consumers' AllowedProviders/modelAccess) and every configured
|
||||
* MCP server/agent target (from GET /admin/api/targets' own `access`
|
||||
* list). Pure data shaping lives in lib/access-matrix.ts; this component
|
||||
* only fetches, lays out columns, and renders.
|
||||
* AccessMatrix (redesign-plan.md section 3.4; P4 three-view split) is the
|
||||
* Consumers page's `view=matrix` alternative to ConsumerDirectory: rows
|
||||
* are groups, columns are ONE of three kinds at a time — providers
|
||||
* (allowed check + "n/m models", from GET /admin/api/consumers'
|
||||
* AllowedProviders/modelAccess), MCP servers, or agents (both from GET
|
||||
* /admin/api/targets' own `access` list) — switched by a Tabs control in
|
||||
* the card header, CONTROLLED via the `kind` prop/`update:kind` emit the
|
||||
* same way AttributionDrilldown.vue's `drill` prop works: this component
|
||||
* owns no navigation state itself, ConsumersPage.vue keeps `kind` in
|
||||
* nav.params (lib/access-matrix.ts's own `matrix` hash param) so a
|
||||
* reload or shared link restores the same view. Pure data shaping lives
|
||||
* in lib/access-matrix.ts; this component only fetches, lays out the
|
||||
* selected kind's columns, and renders.
|
||||
*/
|
||||
const props = defineProps<{ kind: MatrixKind }>()
|
||||
const emit = defineEmits<{ 'update:kind': [kind: MatrixKind] }>()
|
||||
|
||||
const consumers = useConsumersStore()
|
||||
const dashboard = useDashboardStore()
|
||||
const nav = useNavStore()
|
||||
|
||||
onMounted(() => {
|
||||
void consumers.ensureConsumers()
|
||||
@@ -29,27 +46,81 @@ const providerNames = computed(() => (dashboard.overview?.providers ?? []).map((
|
||||
const mcpServers = computed(() => dashboard.targets?.mcpServers ?? [])
|
||||
const agents = computed(() => dashboard.targets?.agents ?? [])
|
||||
|
||||
/** MatrixColumn flattens the three heterogeneous column groups (providers, MCP servers, agents) into one ordered list — a single v-for renders every header AND every cell, rather than three near-identical template blocks. */
|
||||
/** MatrixSection is the AccessMatrixRow key each kind reads its cells from. */
|
||||
type MatrixSection = 'providers' | 'mcpServers' | 'agents'
|
||||
|
||||
/** KIND_SECTION maps the page-facing MatrixKind to the row-shape key buildAccessMatrix produced it under (lib/access-matrix.ts's own AccessMatrixRow). */
|
||||
const KIND_SECTION: Record<MatrixKind, MatrixSection> = {
|
||||
models: 'providers',
|
||||
mcp: 'mcpServers',
|
||||
agents: 'agents',
|
||||
}
|
||||
|
||||
/** KIND_DESCRIPTION is the CardDescription text for the currently selected kind. */
|
||||
const KIND_DESCRIPTION: Record<MatrixKind, string> = {
|
||||
models: "Which groups can reach which providers, and how many of each provider's models.",
|
||||
mcp: 'Which groups can reach which MCP servers.',
|
||||
agents: 'Which groups can reach which agents.',
|
||||
}
|
||||
|
||||
/** KIND_EMPTY_MESSAGE is shown instead of a table with zero columns — distinct from the "no groups configured" state below, since groups can exist while this kind's own column list is empty (e.g. no MCP servers configured at all). */
|
||||
const KIND_EMPTY_MESSAGE: Record<MatrixKind, string> = {
|
||||
models: 'No providers configured.',
|
||||
mcp: 'No MCP servers configured.',
|
||||
agents: 'No agents configured.',
|
||||
}
|
||||
|
||||
/** MatrixColumn is one header/cell column of the currently selected kind — no more "(MCP)"/"(agent)" label suffix, since the Tabs control already says which kind is showing. */
|
||||
interface MatrixColumn {
|
||||
key: string
|
||||
label: string
|
||||
section: 'providers' | 'mcpServers' | 'agents'
|
||||
section: MatrixSection
|
||||
}
|
||||
|
||||
const columns = computed<MatrixColumn[]>(() => [
|
||||
...providerNames.value.map((name): MatrixColumn => ({ key: name, label: name, section: 'providers' })),
|
||||
...mcpServers.value.map((t): MatrixColumn => ({ key: t.name, label: `${t.name} (MCP)`, section: 'mcpServers' })),
|
||||
...agents.value.map((t): MatrixColumn => ({ key: t.name, label: `${t.name} (agent)`, section: 'agents' })),
|
||||
])
|
||||
const columns = computed<MatrixColumn[]>(() => {
|
||||
const section = KIND_SECTION[props.kind]
|
||||
if (props.kind === 'models') return providerNames.value.map((name): MatrixColumn => ({ key: name, label: name, section }))
|
||||
const targets = props.kind === 'mcp' ? mcpServers.value : agents.value
|
||||
return targets.map((t): MatrixColumn => ({ key: t.name, label: t.name, section }))
|
||||
})
|
||||
|
||||
const rows = computed(() => buildAccessMatrix(consumers.data?.groups ?? [], providerNames.value, mcpServers.value, agents.value))
|
||||
|
||||
/** sourceLoaded is whether the CURRENT kind's own raw dashboard field has loaded at least once — dashboard.overview for 'models', dashboard.targets for 'mcp'/'agents' — read straight off the store rather than the derived `columns` list, which maps a not-yet-loaded source to `[]` indistinguishably from a genuinely empty one (lib/access-matrix.ts's matrixColumnsState doc comment). */
|
||||
const sourceLoaded = computed(() => (props.kind === 'models' ? dashboard.overview !== null : dashboard.targets !== null))
|
||||
|
||||
/** columnsState is the current kind's own loading/error/empty/ready state (lib/access-matrix.ts's matrixColumnsState) — drives which CardContent branch renders below, independent of the groups-only groupsLoadState/noGroupsConfigured checks above it. */
|
||||
const columnsState = computed(() => matrixColumnsState(sourceLoaded.value, columns.value.length, dashboard.error))
|
||||
|
||||
/**
|
||||
* groupsLoadState (lib/load-state.ts) replaces the old bare
|
||||
* `consumers.error`/`consumers.loading` checks — those used to show the
|
||||
* error text UNCONDITIONALLY whenever consumers.error was set, even with
|
||||
* perfectly good stale group rows already in `rows` from a PRIOR
|
||||
* successful fetch (fetchConsumers, stores/consumers.ts, never clears
|
||||
* `data` on a failed refetch) — states-plan.md item 1's own "never wipe
|
||||
* rendered data on a background failure" rule, the same class of bug
|
||||
* this states pass fixes everywhere else. 'ready' once consumers.data has
|
||||
* loaded at least once, even with zero groups — `noGroupsConfigured`
|
||||
* below is the separate "loaded, but genuinely nothing configured" case.
|
||||
*/
|
||||
const groupsLoadState = computed(() => loadState({ loading: consumers.loading, hasData: consumers.data !== null, error: consumers.error }))
|
||||
|
||||
/** noGroupsConfigured (states-plan.md item 2: "Consumers: 'No groups configured' + hint to the Config page change helper") is true only once groups have genuinely loaded with none configured — not while still loading or errored (groupsLoadState handles those first, see the template below). */
|
||||
const noGroupsConfigured = computed(() => groupsLoadState.value === 'ready' && rows.value.length === 0)
|
||||
|
||||
const EMPTY_CELL: AccessCell = { allowed: false }
|
||||
|
||||
function cellFor(row: (typeof rows.value)[number], column: MatrixColumn): AccessCell {
|
||||
return row[column.section][column.key] ?? EMPTY_CELL
|
||||
}
|
||||
|
||||
/** setKind forwards a Tabs selection as `update:kind`, guarding the `unknown` reka-ui hands back down to a real MatrixKind (mirrors ConsumersPage.vue's own setView / ChangeHelper.vue's setHelper). */
|
||||
function setKind(next: unknown): void {
|
||||
if (typeof next !== 'string' || !(MATRIX_KINDS as readonly string[]).includes(next)) return
|
||||
emit('update:kind', next as MatrixKind)
|
||||
}
|
||||
|
||||
/**
|
||||
* cellSrText is the ONLY accessible text a screen reader announces for
|
||||
* one cell — the visible icon is aria-hidden and the visible detail span
|
||||
@@ -84,24 +155,45 @@ function cellIconClass(cell: AccessCell): string {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Access matrix</CardTitle>
|
||||
<CardDescription>Which groups can reach which providers, MCP servers, and agents.</CardDescription>
|
||||
<CardDescription>{{ KIND_DESCRIPTION[props.kind] }}</CardDescription>
|
||||
<Tabs :model-value="props.kind" @update:model-value="setKind">
|
||||
<TabsList>
|
||||
<TabsTrigger v-for="k in MATRIX_KINDS" :key="k" :value="k">{{ MATRIX_KIND_LABEL[k] }}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</CardHeader>
|
||||
<CardContent v-if="consumers.error" class="text-sm text-destructive">{{ consumers.error }}</CardContent>
|
||||
<CardContent v-else-if="rows.length === 0" class="py-6 text-center text-sm text-muted-foreground">
|
||||
{{ consumers.loading ? 'loading…' : 'no groups configured' }}
|
||||
<CardContent v-if="groupsLoadState === 'error'">
|
||||
<ErrorState :message="consumers.error" :on-retry="consumers.fetchConsumers" />
|
||||
</CardContent>
|
||||
<CardContent v-else-if="groupsLoadState === 'skeleton'">
|
||||
<SkeletonTable :rows="4" :cols="4" />
|
||||
</CardContent>
|
||||
<CardContent v-else-if="noGroupsConfigured">
|
||||
<EmptyState title="No groups configured." description="Grant a group access from the Config page's change helper.">
|
||||
<Button type="button" variant="outline" size="sm" @click="nav.goTo('config', { helper: 'grant' })">Open change helper</Button>
|
||||
</EmptyState>
|
||||
</CardContent>
|
||||
<CardContent v-else-if="columnsState === 'error'">
|
||||
<ErrorState :message="dashboard.error" />
|
||||
</CardContent>
|
||||
<CardContent v-else-if="columnsState === 'loading'">
|
||||
<SkeletonTable :rows="rows.length" :cols="4" />
|
||||
</CardContent>
|
||||
<CardContent v-else-if="columnsState === 'empty'">
|
||||
<EmptyState :title="KIND_EMPTY_MESSAGE[props.kind]" />
|
||||
</CardContent>
|
||||
<CardContent v-else class="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Group</TableHead>
|
||||
<TableHead v-for="column in columns" :key="column.key + column.section">{{ column.label }}</TableHead>
|
||||
<TableHead v-for="column in columns" :key="column.key">{{ column.label }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="row in rows" :key="row.group">
|
||||
<TableHead scope="row" class="font-medium">{{ row.group }}</TableHead>
|
||||
<TableCell v-for="column in columns" :key="column.key + column.section" class="text-center" :title="cellFor(row, column).detail">
|
||||
<TableCell v-for="column in columns" :key="column.key" class="text-center" :title="cellFor(row, column).detail">
|
||||
<FontAwesomeIcon :icon="cellIcon(cellFor(row, column))" :class="cellIconClass(cellFor(row, column))" class="size-3.5" aria-hidden="true" />
|
||||
<span v-if="cellFor(row, column).detail" aria-hidden="true" class="ml-1 text-xs text-muted-foreground">{{ cellFor(row, column).detail }}</span>
|
||||
<span class="sr-only">{{ cellSrText(cellFor(row, column)) }}</span>
|
||||
|
||||
@@ -7,9 +7,17 @@ import { computed, h, ref } from 'vue'
|
||||
import ClampedRangeNotice from '@/components/ClampedRangeNotice.vue'
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import EntityLink from '@/components/EntityLink.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import SkeletonTable from '@/components/SkeletonTable.vue'
|
||||
import TryLongerRangeButton from '@/components/TryLongerRangeButton.vue'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatCost } from '@/lib/format'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
import { SPEND_METRIC_LABEL } from '@/stores/spend'
|
||||
import type { AdminTotalsResponse, HistoryMetric, HistoryWindow } from '@/types/api'
|
||||
|
||||
@@ -44,10 +52,26 @@ const props = defineProps<{
|
||||
/** The global filters store's current window/span (N3 fix) — ClampedRangeNotice's own props, forwarded so this stays a controlled, props-driven component rather than reaching into stores/filters.ts itself. */
|
||||
window: HistoryWindow
|
||||
span: number
|
||||
/** spend.drilldownLoading (verify-ui-states.md #3 fix) — this card had no loading/error input at all: `spend.drilldownRows` starts as [], so the very first Spend load, and any failed drilldown fetch, rendered the identical "No traffic in this range." a genuinely empty group/user/model list gets. */
|
||||
loading: boolean
|
||||
/**
|
||||
* spend.drilldownLoaded (verify-ui-states-2.md #2 fix) — "has a fetch
|
||||
* completed for the current window/span/drill selection", not
|
||||
* `rows.length > 0`: the 60s poll flips `loading` true on every tick, and
|
||||
* a genuinely empty level (e.g. a group with no users) used to flash a
|
||||
* skeleton back on for each tick. `noTrafficInRange` below still checks
|
||||
* `rows.length` directly for EmptyState vs. the real table.
|
||||
*/
|
||||
loaded: boolean
|
||||
error: string
|
||||
/** Renders ErrorState's own "Retry" button — SpendPage.vue's own spend.fetchDrilldown. */
|
||||
onRetry?: () => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:drill': [value: string] }>()
|
||||
|
||||
const nav = useNavStore()
|
||||
|
||||
type Level = { kind: 'group' } | { kind: 'user'; group: string } | { kind: 'usermodel'; user: string }
|
||||
|
||||
const level = computed<Level>(() => {
|
||||
@@ -122,6 +146,22 @@ const columns = computed<ColumnDef<DrilldownRow, unknown>[]>(() => [
|
||||
])
|
||||
|
||||
const showUserModelHint = computed<boolean>(() => level.value.kind === 'user' && (!props.userModelStatsEnabled || props.disabled))
|
||||
|
||||
const state = computed(() => loadState({ loading: props.loading, hasData: props.loaded, error: props.error }))
|
||||
|
||||
/**
|
||||
* noTrafficInRange (states-plan.md item 2: "Spend/Models/Home lists: 'No
|
||||
* traffic in this range'") is the genuine "nothing to show" case — gated
|
||||
* directly on `rows.length === 0` (verify-ui-states-2.md #2 fix; `state`
|
||||
* no longer distinguishes empty from non-empty once `loaded` is true, see
|
||||
* this component's own `loaded` prop doc comment), the same way
|
||||
* UserDetail.vue's own "Models used" table checks `!modelRows.length`. The
|
||||
* template below still checks `state === 'skeleton'`/`'error'` FIRST, so a
|
||||
* first/failed fetch never reaches this branch. Never true alongside
|
||||
* showUserModelHint, which takes precedence in the template below (that
|
||||
* state has its own, more specific copy).
|
||||
*/
|
||||
const noTrafficInRange = computed<boolean>(() => !showUserModelHint.value && props.rows.length === 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -149,15 +189,19 @@ const showUserModelHint = computed<boolean>(() => level.value.kind === 'user' &&
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<ClampedRangeNotice v-if="level.kind === 'usermodel'" :window="window" :span="span" />
|
||||
<p v-if="showUserModelHint" class="text-sm text-muted-foreground">
|
||||
Per-model attribution for this user needs <code class="font-mono text-xs">admin.stats.userModel</code> enabled.
|
||||
</p>
|
||||
<DataTable
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:empty-message="level.kind === 'group' ? 'No group has spend in this range.' : 'Nothing in this range.'"
|
||||
/>
|
||||
<Alert v-if="showUserModelHint" variant="warn">
|
||||
<AlertTitle>Per-user-model statistics are off</AlertTitle>
|
||||
<AlertDescription class="flex flex-wrap items-center gap-2">
|
||||
<span>Enable <code class="font-mono text-xs">admin.stats.userModel</code> in the middleware config to see per-model attribution for this user.</span>
|
||||
<Button type="button" variant="outline" size="sm" @click="nav.goTo('config')">Open Config</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<SkeletonTable v-else-if="state === 'skeleton'" :rows="5" :cols="metrics.length + 1" />
|
||||
<ErrorState v-else-if="state === 'error'" :message="error" :on-retry="onRetry" />
|
||||
<EmptyState v-else-if="noTrafficInRange" title="No traffic in this range.">
|
||||
<TryLongerRangeButton />
|
||||
</EmptyState>
|
||||
<DataTable v-else :columns="columns" :data="rows" empty-message="none" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -115,7 +115,7 @@ watch(() => auth.error, (err) => {
|
||||
<FontAwesomeIcon :icon="showKey ? faEyeSlash : faEye" class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="submit" class="w-full" :disabled="!canSubmit">
|
||||
<Button type="submit" class="h-10 w-full" :disabled="!canSubmit">
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import SkeletonChart from '@/components/SkeletonChart.vue'
|
||||
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
|
||||
import type { TimeSeriesDataset } from '@/components/TimeSeriesChart.vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { cumulative, runOutDate } from '@/lib/burndown'
|
||||
import { formatBucketLabel, formatCost } from '@/lib/format'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
|
||||
/**
|
||||
* BurnDownChart (redesign-plan.md section 3.4) renders the current UTC
|
||||
@@ -34,8 +37,15 @@ const props = defineProps<{
|
||||
/** The scope's configured cost/month budget in micro-USD, or null when none is configured. */
|
||||
budgetMicros: number | null
|
||||
now: Date
|
||||
/** spend.burndownLoading (verify-ui-states.md #7 fix, Spend page gap) — this card had no loading/error input at all before: `buckets` starts as [], so the very first Spend load, and any failed burn-down fetch, rendered a flat all-zero chart with no indication either was happening. */
|
||||
loading?: boolean
|
||||
error?: string
|
||||
/** Renders ErrorState's own "Retry" button — SpendPage.vue's own spend.refresh (fetchBurndown has no standalone public retry of its own). */
|
||||
onRetry?: () => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const state = computed(() => loadState({ loading: props.loading ?? false, hasData: props.buckets.length > 0, error: props.error ?? '' }))
|
||||
|
||||
const labels = computed<string[]>(() => props.buckets.map((b) => formatBucketLabel(b, 'day')))
|
||||
const cumulativePoints = computed<number[]>(() => cumulative(props.points))
|
||||
const spentMicros = computed<number>(() => cumulativePoints.value.at(-1) ?? 0)
|
||||
@@ -60,33 +70,37 @@ const willExceed = computed<boolean>(() => props.budgetMicros !== null && spentM
|
||||
<CardDescription>Cumulative spend this UTC calendar month against the configured budget.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Spent so far</p>
|
||||
<p class="text-lg font-semibold tabular-nums" :class="willExceed ? 'text-destructive' : undefined">
|
||||
{{ formatCost(spentMicros) }}
|
||||
</p>
|
||||
<SkeletonChart v-if="state === 'skeleton'" />
|
||||
<ErrorState v-else-if="state === 'error'" :message="error ?? ''" :on-retry="onRetry" />
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Spent so far</p>
|
||||
<p class="text-lg font-semibold tabular-nums" :class="willExceed ? 'text-destructive' : undefined">
|
||||
{{ formatCost(spentMicros) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="budgetMicros !== null">
|
||||
<p class="text-muted-foreground">Budget</p>
|
||||
<p class="text-lg font-semibold tabular-nums">{{ formatCost(budgetMicros) }}</p>
|
||||
</div>
|
||||
<div v-if="budgetMicros !== null">
|
||||
<p class="text-muted-foreground">Projected run-out</p>
|
||||
<p v-if="runOut === null" class="text-sm text-muted-foreground">not enough data yet</p>
|
||||
<p v-else class="text-lg font-semibold tabular-nums" :class="willExceed ? 'text-destructive' : undefined">
|
||||
{{ runOut.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="budgetMicros !== null">
|
||||
<p class="text-muted-foreground">Budget</p>
|
||||
<p class="text-lg font-semibold tabular-nums">{{ formatCost(budgetMicros) }}</p>
|
||||
</div>
|
||||
<div v-if="budgetMicros !== null">
|
||||
<p class="text-muted-foreground">Projected run-out</p>
|
||||
<p v-if="runOut === null" class="text-sm text-muted-foreground">not enough data yet</p>
|
||||
<p v-else class="text-lg font-semibold tabular-nums" :class="willExceed ? 'text-destructive' : undefined">
|
||||
{{ runOut.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="budgetMicros === null" class="text-sm text-muted-foreground">No budget configured for this scope.</p>
|
||||
<TimeSeriesChart
|
||||
:labels="labels"
|
||||
:datasets="datasets"
|
||||
:comparison-datasets="comparisonDatasets"
|
||||
:value-formatter="formatCost"
|
||||
ariaLabel="Cumulative spend this month against the configured budget"
|
||||
/>
|
||||
<p v-if="budgetMicros === null" class="text-sm text-muted-foreground">No budget configured for this scope.</p>
|
||||
<TimeSeriesChart
|
||||
:labels="labels"
|
||||
:datasets="datasets"
|
||||
:comparison-datasets="comparisonDatasets"
|
||||
:value-formatter="formatCost"
|
||||
ariaLabel="Cumulative spend this month against the configured budget"
|
||||
/>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -6,11 +6,16 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import CostForecastCard from '@/components/CostForecastCard.vue'
|
||||
import CsvExportButton from '@/components/CsvExportButton.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import ModelChip from '@/components/ModelChip.vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import SkeletonList from '@/components/SkeletonList.vue'
|
||||
import SkeletonTable from '@/components/SkeletonTable.vue'
|
||||
import SortHeaderButton from '@/components/SortHeaderButton.vue'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { valueUpdater } from '@/components/ui/table'
|
||||
import UsageBar from '@/components/UsageBar.vue'
|
||||
@@ -19,6 +24,7 @@ import { useNow } from '@/composables/useNow'
|
||||
import { useSearchQuery } from '@/composables/useSearchQuery'
|
||||
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
|
||||
import { formatCost, formatExactInt, formatLimits } from '@/lib/format'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
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'
|
||||
@@ -46,6 +52,23 @@ const consumers = useConsumersStore()
|
||||
const nav = useNavStore()
|
||||
const usage = computed(() => dashboard.usage)
|
||||
|
||||
/**
|
||||
* dashboardLoadState (verify-ui-states.md #3) gates the Groups/Users
|
||||
* cards' own skeleton/error — `hasData` is "has GET /admin/api/usage EVER
|
||||
* landed", not "are there any groups/users": a fleet with genuinely zero
|
||||
* groups/users still reads 'ready' and falls through to the EXISTING
|
||||
* noGroupsConfigured/usersEmptyMessage copy below, unchanged. Mirrors
|
||||
* HomePage.vue's own identical kpiTilesState convention (verify-ui-states-2.
|
||||
* md #11: this comment named the old kpiTilesLoading identifier, since
|
||||
* renamed) — both read the same polled dashboard store. Previously
|
||||
* `noGroupsConfigured`/
|
||||
* `usersEmptyMessage` read `usage.value?.groups/users.length ?? 0 === 0`
|
||||
* directly, which was ALSO true before the first successful fetch (or
|
||||
* after a failed one) — "No groups/users configured" rendered as a false
|
||||
* fleet fact while the real state was "not loaded yet" or "failed".
|
||||
*/
|
||||
const dashboardLoadState = computed(() => loadState({ loading: !dashboard.lastUpdated, hasData: usage.value !== null, error: dashboard.error }))
|
||||
|
||||
onMounted(() => {
|
||||
void consumers.ensureConsumers()
|
||||
})
|
||||
@@ -117,6 +140,9 @@ const groupsEmptyMessage = computed(() =>
|
||||
!usage.value?.groups.length ? 'none configured' : hasQuery.value ? `no groups match "${userQuery.value}"` : 'none',
|
||||
)
|
||||
|
||||
/** noGroupsConfigured (states-plan.md item 2: "Consumers: 'No groups configured' + hint to the Config page change helper") — mirrors AccessMatrix.vue's identical own noGroupsConfigured, the Access matrix view's own copy of this same "Consumers" page fact. Only true when genuinely nothing is configured, never while a search query merely matches none (groupsEmptyMessage's own "no groups match" case keeps its plain inline text — a filter producing zero rows is a routine interaction, not a fleet-configuration fact worth the same visual weight). */
|
||||
const noGroupsConfigured = computed(() => (usage.value?.groups.length ?? 0) === 0 && !hasQuery.value)
|
||||
|
||||
/** filteredUsers is every user matching the query directly, PLUS every member of a group that matched by NAME. */
|
||||
const filteredUsers = computed<AdminUsageEntryView[]>(() => {
|
||||
const all = usage.value?.users ?? []
|
||||
@@ -301,6 +327,9 @@ function groupsCsv(): string {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SkeletonList v-if="dashboardLoadState === 'skeleton'" :rows="3" />
|
||||
<ErrorState v-else-if="dashboardLoadState === 'error'" :message="dashboard.error" :on-retry="dashboard.refresh" />
|
||||
<template v-else>
|
||||
<div v-if="sortedGroupRows.length" class="mb-3 flex flex-wrap gap-x-4 gap-y-1 text-xs font-medium text-muted-foreground">
|
||||
<SortHeaderButton v-for="header in toolbarHeaders" :key="header.id" :header="header" />
|
||||
</div>
|
||||
@@ -334,7 +363,8 @@ function groupsCsv(): string {
|
||||
:key="providerName"
|
||||
as="span"
|
||||
variant="secondary"
|
||||
class="font-mono font-normal"
|
||||
class="max-w-full truncate font-mono font-normal"
|
||||
:title="providerName"
|
||||
>
|
||||
{{ providerName }}
|
||||
</Badge>
|
||||
@@ -348,7 +378,7 @@ function groupsCsv(): string {
|
||||
<div v-if="row.original.models?.length" class="flex flex-wrap gap-1.5">
|
||||
<template v-for="modelId in row.original.models" :key="modelId">
|
||||
<ModelChip v-if="!isGlobPattern(modelId)" :id="modelId" />
|
||||
<Badge v-else as="span" variant="secondary" class="font-mono font-normal">{{ modelId }}</Badge>
|
||||
<Badge v-else as="span" variant="secondary" class="max-w-full truncate font-mono font-normal" :title="modelId">{{ modelId }}</Badge>
|
||||
</template>
|
||||
</div>
|
||||
<span v-else class="text-muted-foreground">All models</span>
|
||||
@@ -363,7 +393,8 @@ function groupsCsv(): string {
|
||||
:key="serverName"
|
||||
as="span"
|
||||
variant="secondary"
|
||||
class="font-mono font-normal"
|
||||
class="max-w-full truncate font-mono font-normal"
|
||||
:title="serverName"
|
||||
>
|
||||
{{ serverName }}
|
||||
</Badge>
|
||||
@@ -375,7 +406,14 @@ function groupsCsv(): string {
|
||||
<dt class="text-xs text-muted-foreground" :title="GLOB_HINT">Agents</dt>
|
||||
<dd>
|
||||
<div v-if="row.original.agents?.length" class="flex flex-wrap gap-1.5">
|
||||
<Badge v-for="agentName in row.original.agents" :key="agentName" as="span" variant="secondary" class="font-mono font-normal">
|
||||
<Badge
|
||||
v-for="agentName in row.original.agents"
|
||||
:key="agentName"
|
||||
as="span"
|
||||
variant="secondary"
|
||||
class="max-w-full truncate font-mono font-normal"
|
||||
:title="agentName"
|
||||
>
|
||||
{{ agentName }}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -467,7 +505,11 @@ function groupsCsv(): string {
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<EmptyState v-else-if="noGroupsConfigured" title="No groups configured." description="Grant a group access from the Config page's change helper.">
|
||||
<Button type="button" variant="outline" size="sm" @click="nav.goTo('config', { helper: 'grant' })">Open change helper</Button>
|
||||
</EmptyState>
|
||||
<p v-else class="py-6 text-center text-sm text-muted-foreground">{{ groupsEmptyMessage }}</p>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -482,7 +524,10 @@ function groupsCsv(): string {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SkeletonTable v-if="dashboardLoadState === 'skeleton'" :rows="5" :cols="5" />
|
||||
<ErrorState v-else-if="dashboardLoadState === 'error'" :message="dashboard.error" :on-retry="dashboard.refresh" />
|
||||
<UsageTable
|
||||
v-else
|
||||
id-label="Name"
|
||||
secondary-column-label="Group"
|
||||
:entries="filteredUsers"
|
||||
|
||||
@@ -60,6 +60,14 @@ async function onClick(): Promise<void> {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
// Inline feedback ONLY (verify-ui-states.md #9 fix) — this used to
|
||||
// ALSO push a toast on failure, on top of the button's own "Export
|
||||
// failed" label/icon swap right at the click target: two
|
||||
// notifications for the one event, exactly the double-notify
|
||||
// states-plan.md item 3 says to avoid ("keep one, don't
|
||||
// double-notify"). The inline state is immediate, contextual, and
|
||||
// already dismisses itself after FEEDBACK_MS — a toast added nothing
|
||||
// a reader watching the button they just clicked wouldn't already see.
|
||||
feedback.value = await downloadText(props.filename, props.build())
|
||||
clearFeedbackAfterDelay()
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { faInbox } from '@fortawesome/free-solid-svg-icons'
|
||||
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
/**
|
||||
* EmptyState is the ONE shared "nothing to show, and that's expected"
|
||||
* placeholder every table/list/card in this panel uses instead of an
|
||||
* ad-hoc "no X configured"/"no traffic" text line (states-plan.md item
|
||||
* 2) — an icon, a short title, a one-line description, and an optional
|
||||
* action (a button/link via the default slot, e.g. "Try a longer range"
|
||||
* or a link to the Config page's change helper). `role="status"` — this
|
||||
* is informational, not an error, so it is announced politely
|
||||
* (aria-live="polite" semantics), matching ErrorState.vue's own
|
||||
* `role="alert"` counterpart for the destructive case.
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
icon?: IconDefinition
|
||||
title: string
|
||||
description?: string
|
||||
}>(),
|
||||
{ icon: () => faInbox },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div role="status" class="flex flex-col items-center gap-2 px-4 py-8 text-center">
|
||||
<FontAwesomeIcon :icon="icon" class="size-6 text-muted-foreground/60" aria-hidden="true" />
|
||||
<p class="text-sm font-medium text-foreground">{{ title }}</p>
|
||||
<p v-if="description" class="max-w-sm text-sm text-muted-foreground">{{ description }}</p>
|
||||
<div v-if="$slots.default" class="mt-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -27,6 +27,19 @@ const props = defineProps<{
|
||||
kind: 'user' | 'model'
|
||||
/** The raw entity id (never pre-formatted as "kind:id" — this component builds nav.goTo's own params itself). */
|
||||
id: string
|
||||
/**
|
||||
* Applied to the inner label `<span>`, never the root `<button>`
|
||||
* (verify-ui-states.md #10 fix). A caller that used to pass `truncate`
|
||||
* through the root's own fallthrough `class` got a hard clip with no
|
||||
* "…" and the trailing icon clipped away too: `text-overflow: ellipsis`
|
||||
* only renders on the element whose own overflow is hidden, and
|
||||
* `min-width: auto` on an inline-flex child ignores the parent's
|
||||
* shrunk width — the label never actually shrank enough to overflow.
|
||||
* Passing `truncate` here instead lets the label span (which also gets
|
||||
* `min-w-0` unconditionally, see the template) genuinely truncate with
|
||||
* an ellipsis while the icon (`shrink-0`) stays fully visible.
|
||||
*/
|
||||
labelClass?: string
|
||||
}>()
|
||||
|
||||
const nav = useNavStore()
|
||||
@@ -48,10 +61,10 @@ function onClick(event: MouseEvent): void {
|
||||
type="button"
|
||||
:aria-label="`View ${label} in ${TARGET[kind].noun}`"
|
||||
:title="`View ${label} in ${TARGET[kind].noun}`"
|
||||
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"
|
||||
class="inline-flex min-w-0 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="faArrowUpRightFromSquare" class="size-3" aria-hidden="true" />
|
||||
<span class="min-w-0" :class="labelClass">{{ label }}</span>
|
||||
<FontAwesomeIcon :icon="faArrowUpRightFromSquare" class="size-3 shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { faArrowRotateRight, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/**
|
||||
* ErrorState is EmptyState.vue's destructive counterpart (states-plan.md
|
||||
* item 2) — a fetch for the CURRENT selection failed and there is no
|
||||
* data underneath it to fall back to (lib/load-state.ts's loadState:
|
||||
* 'error', not 'ready' — a background refresh failure while data is
|
||||
* ALREADY shown never reaches this component, it surfaces via a toast
|
||||
* instead, stores/toasts.ts). `message` is the server/network error
|
||||
* string already surfaced by lib/api.ts's AdminApiError, shown verbatim
|
||||
* — never re-worded, so what the reader sees matches what actually
|
||||
* failed. `onRetry`, when given, renders a "Retry" button calling the
|
||||
* store's own refresh/ensure action; omitted for a view with no single
|
||||
* refresh action to re-run (rare — most stores in this panel expose
|
||||
* one). `role="alert"` announces this assertively, unlike EmptyState's
|
||||
* polite `role="status"`.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
message: string
|
||||
onRetry?: () => void | Promise<void>
|
||||
}>()
|
||||
|
||||
/**
|
||||
* retrying (verify-ui-states.md #6 lows: "Retry shows progress (disabled +
|
||||
* spinner/text) while retrying") is LOCAL, not read off the caller's own
|
||||
* store loading flag — several call sites' `onRetry` (consumers.
|
||||
* fetchConsumers, events.refresh) do not clear their store's `error`
|
||||
* before re-fetching, so `loadState` would stay 'error' with no in-flight
|
||||
* signal at all if this depended on that. Tracking the retry promise here
|
||||
* instead works for every caller uniformly, and disabling the button while
|
||||
* it is in flight also closes the "repeated clicks fire parallel requests"
|
||||
* gap those same actions have no guard against.
|
||||
*/
|
||||
const retrying = ref(false)
|
||||
|
||||
async function onRetryClick(): Promise<void> {
|
||||
if (!props.onRetry || retrying.value) return
|
||||
retrying.value = true
|
||||
try {
|
||||
await props.onRetry()
|
||||
} finally {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div role="alert" class="flex flex-col items-center gap-2 px-4 py-8 text-center">
|
||||
<FontAwesomeIcon :icon="faTriangleExclamation" class="size-6 text-destructive" aria-hidden="true" />
|
||||
<p class="max-w-full break-words text-sm font-medium text-destructive">{{ message }}</p>
|
||||
<Button v-if="onRetry" type="button" variant="outline" size="sm" class="mt-1" :disabled="retrying" @click="onRetryClick">
|
||||
<FontAwesomeIcon
|
||||
:icon="faArrowRotateRight"
|
||||
class="size-3.5"
|
||||
:class="retrying ? 'animate-spin motion-reduce:animate-none' : undefined"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ retrying ? 'Retrying…' : 'Retry' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,16 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faCircleCheck, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import SkeletonTable from '@/components/SkeletonTable.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 { loadState } from '@/lib/load-state'
|
||||
import { useEventsStore } from '@/stores/events'
|
||||
|
||||
// This component backs the new "Events" tab (App.vue, F3) — GET
|
||||
@@ -49,21 +53,42 @@ function onKindChange(value: unknown): void {
|
||||
const columns = eventsColumns()
|
||||
|
||||
/**
|
||||
* emptyMessage mirrors TargetsView.vue/ConsumerDirectory.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.
|
||||
* eventsLoadState (lib/load-state.ts) replaces the old lone
|
||||
* `!events.lastUpdated` check this view used everywhere: 'skeleton' for
|
||||
* the first fetch, 'error' when that first fetch itself failed (P9's own
|
||||
* false-negative bug — an initial-load FAILURE used to render the exact
|
||||
* same "loading…" text as a genuine in-flight load, forever, since
|
||||
* events.error was never even read here), 'ready' once there is a
|
||||
* successful fetch to show (an EMPTY events list after a successful
|
||||
* fetch is still 'ready' — DataTable's own emptyMessage below renders
|
||||
* the real "no events recorded" row, not this card-level state). A
|
||||
* LATER background-poll failure, with events already on screen, stays
|
||||
* 'ready' too (stores/events.ts's own toast covers that case instead —
|
||||
* states-plan.md item 3 — so content is never wiped/replaced by a
|
||||
* skeleton or error here).
|
||||
*/
|
||||
const eventsLoadState = computed(() => loadState({ loading: events.refreshing, hasData: events.lastUpdated !== null, error: events.error }))
|
||||
|
||||
/**
|
||||
* noEventsRecorded is the GENUINE "nothing has ever happened" case
|
||||
* (states-plan.md item 2: "Events/Reliability: 'No failures recorded'
|
||||
* (positive tone, success icon)") — renders EmptyState instead of an
|
||||
* empty DataTable row, since a clean fleet is worth a reassuring visual,
|
||||
* not just quiet table text. Distinct from "nothing matches the active
|
||||
* filter" below, which stays a plain inline DataTable row: a filter
|
||||
* producing zero rows is a routine, low-stakes interaction, not a
|
||||
* fleet-health signal worth the same visual weight.
|
||||
*
|
||||
* The copy below deliberately does NOT say "in this range" (verify-ui-
|
||||
* states.md #11 fix): the events store fetches the last EVENTS_LIMIT
|
||||
* events (stores/events.ts: GET /admin/api/events?limit=200), unscoped by
|
||||
* the global range filter — "in this range" would misleadingly imply a
|
||||
* narrower window filtered this list down to zero.
|
||||
*/
|
||||
const noEventsRecorded = computed(() => events.events.length === 0)
|
||||
|
||||
/** emptyMessage is DataTable's own inline empty-row text for the "some events exist, but the active filter matches none" case — only ever rendered while noEventsRecorded is false (see the template below). */
|
||||
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'
|
||||
})
|
||||
@@ -71,17 +96,11 @@ const emptyMessage = computed(() => {
|
||||
/**
|
||||
* 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.
|
||||
* fallback as if it were the fleet-wide picture. Only rendered while
|
||||
* eventsLoadState is 'ready' (see the template below), so this never
|
||||
* reads the store's pristine initial source/replica/capacity values.
|
||||
*/
|
||||
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`
|
||||
})
|
||||
@@ -109,9 +128,8 @@ onUnmounted(() => {
|
||||
<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>
|
||||
<!-- Only rendered once there is a real fetch to describe (eventsLoadState 'ready') — 'skeleton'/'error' show nothing here, the CardContent body below already signals which of those it is. -->
|
||||
<CardDescription v-if="eventsLoadState === 'ready'">
|
||||
{{ sourceCaption }} — rate limit, budget, and upstream events, newest first (capacity
|
||||
{{ events.capacity }}).
|
||||
</CardDescription>
|
||||
@@ -130,8 +148,15 @@ onUnmounted(() => {
|
||||
</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" />
|
||||
<SkeletonTable v-if="eventsLoadState === 'skeleton'" :rows="5" :cols="columns.length" />
|
||||
<ErrorState v-else-if="eventsLoadState === 'error'" :message="events.error" :on-retry="events.refresh" />
|
||||
<EmptyState
|
||||
v-else-if="noEventsRecorded"
|
||||
:icon="faCircleCheck"
|
||||
title="No failures recorded."
|
||||
description="Rate-limit, budget, and upstream events will show up here as they happen."
|
||||
/>
|
||||
<DataTable v-else :columns="columns" :data="filteredEvents" :empty-message="emptyMessage" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import SkeletonTable from '@/components/SkeletonTable.vue'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { filterModelRows } from '@/lib/model-filter'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
import { modelCatalogColumns } from '@/lib/model-table-columns'
|
||||
import type { ModelCatalogRow } from '@/lib/model-table-columns'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
|
||||
/**
|
||||
* ModelCatalogTable (redesign-plan.md section 3.4, Models & providers
|
||||
@@ -27,17 +33,35 @@ const props = defineProps<{
|
||||
/** GET /admin/api/performance's own latencyEnabled (AdminPerfResponse) — false means admin.stats.latency is off, so p50/p95 read "—" for every row, not "no traffic yet". */
|
||||
latencyEnabled: boolean
|
||||
loading: boolean
|
||||
/** models.error (verify-ui-states.md #3 fix) — ModelsPage.vue's own current-fetch error, '' when there is none. Previously this component always passed error: '' into loadState below, so a fetch that failed with no rows yet rendered "No models configured." (a false fleet fact) instead of ErrorState. */
|
||||
error?: string
|
||||
/** Renders ErrorState's own "Retry" button — ModelsPage.vue's own models.fetch. */
|
||||
onRetry?: () => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:query': [value: string] }>()
|
||||
|
||||
const nav = useNavStore()
|
||||
|
||||
const filtered = computed<ModelCatalogRow[]>(() => filterModelRows(props.rows, props.query))
|
||||
|
||||
const columns = modelCatalogColumns()
|
||||
|
||||
/**
|
||||
* catalogLoadState (lib/load-state.ts, verify-ui-states.md #3 fix) now
|
||||
* reads the REAL `error` prop, not a hardcoded ''. A not-yet-loaded (or
|
||||
* failed) catalog used to render "No models configured." — a false fleet
|
||||
* fact — for BOTH "still fetching" and "the fetch failed with zero rows"
|
||||
* cases alike, since `error: ''` meant loadState could only ever resolve
|
||||
* to 'skeleton' or 'empty', never 'error'. ModelsPage.vue's own top-level
|
||||
* `models.error` paragraph stays alongside this (it also covers a
|
||||
* background refresh failure while rows are already on screen, which
|
||||
* never reaches this component's own ErrorState — loadState's 'ready'
|
||||
* precedence keeps existing rows on screen through that case).
|
||||
*/
|
||||
const catalogLoadState = computed(() => loadState({ loading: props.loading, hasData: props.rows.length > 0, error: props.error ?? '' }))
|
||||
|
||||
const emptyMessage = computed<string>(() => {
|
||||
if (props.loading) return 'loading…'
|
||||
if (props.rows.length === 0) return 'no models configured'
|
||||
if (props.query.trim()) return `no models match "${props.query}"`
|
||||
return 'none'
|
||||
})
|
||||
@@ -59,11 +83,16 @@ const emptyMessage = computed<string>(() => {
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<Alert v-if="!latencyEnabled" variant="warn">
|
||||
<AlertDescription>
|
||||
Latency percentiles (p50/p95) are off for this deployment. Enable <code>admin.stats.latency</code> to see them.
|
||||
<AlertTitle>Latency statistics are off</AlertTitle>
|
||||
<AlertDescription class="flex flex-wrap items-center gap-2">
|
||||
<span>Enable <code>admin.stats.latency</code> in the middleware config to see fleet p50/p95.</span>
|
||||
<Button type="button" variant="outline" size="sm" @click="nav.goTo('config')">Open Config</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<DataTable :columns="columns" :data="filtered" :empty-message="emptyMessage" />
|
||||
<SkeletonTable v-if="catalogLoadState === 'skeleton'" :rows="5" :cols="columns.length" />
|
||||
<ErrorState v-else-if="catalogLoadState === 'error'" :message="error ?? ''" :on-retry="onRetry" />
|
||||
<EmptyState v-else-if="catalogLoadState === 'empty'" title="No models configured." />
|
||||
<DataTable v-else :columns="columns" :data="filtered" :empty-message="emptyMessage" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed, onUnmounted, ref, useTemplateRef } from 'vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { copyText } from '@/lib/clipboard'
|
||||
import { formatContextWindow, formatModelCostHover } from '@/lib/format'
|
||||
import { useToastsStore } from '@/stores/toasts'
|
||||
|
||||
/**
|
||||
* ModelChip is one clickable, copyable model-id chip, used anywhere a
|
||||
@@ -51,6 +52,7 @@ const props = defineProps<{
|
||||
const labelRef = useTemplateRef<HTMLElement>('label')
|
||||
const state = ref<'idle' | 'copied' | 'selected' | 'failed'>('idle')
|
||||
let resetTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const toasts = useToastsStore()
|
||||
|
||||
function scheduleReset(): void {
|
||||
clearTimeout(resetTimer)
|
||||
@@ -64,8 +66,13 @@ async function onClick(): Promise<void> {
|
||||
// 'failed' (neither the Clipboard API nor the selection fallback
|
||||
// worked) gets its own visible state rather than a silent no-op — a
|
||||
// click that visibly does nothing reads as a broken button, not as
|
||||
// "nothing to report".
|
||||
// "nothing to report". Only a FAILURE also gets a toast (states-plan.md
|
||||
// item 3) — 'copied'/'selected' already have clear, accessible inline
|
||||
// feedback right at the click target (the icon swap plus this same
|
||||
// title/aria-label text), so a success toast on top would double-notify
|
||||
// for something the reader is already looking straight at.
|
||||
state.value = result
|
||||
if (result === 'failed') toasts.push({ kind: 'error', message: `Could not copy ${props.id}.` })
|
||||
scheduleReset()
|
||||
}
|
||||
|
||||
@@ -123,13 +130,13 @@ onUnmounted(() => {
|
||||
as="button"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
class="cursor-pointer gap-1 font-mono text-xs font-normal transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
class="max-w-full cursor-pointer gap-1 truncate font-mono text-xs font-normal transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
:class="state === 'failed' ? 'text-destructive' : ''"
|
||||
:title="title"
|
||||
:aria-label="title"
|
||||
@click="onClick"
|
||||
>
|
||||
<span ref="label" class="select-text">{{ id }}</span>
|
||||
<span ref="label" class="min-w-0 truncate select-text">{{ id }}</span>
|
||||
<FontAwesomeIcon :icon="icon[state]" class="size-2.5 shrink-0" aria-hidden="true" />
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
@@ -5,11 +5,16 @@ import { computed, h } from 'vue'
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import CsvExportButton from '@/components/CsvExportButton.vue'
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import EntityLink from '@/components/EntityLink.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import SkeletonTable from '@/components/SkeletonTable.vue'
|
||||
import TryLongerRangeButton from '@/components/TryLongerRangeButton.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { toCsv } from '@/lib/csv'
|
||||
import { formatContextWindow, formatCost, formatModelCostHover } from '@/lib/format'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
import { joinPricingHealth, pricingHealthOrder } from '@/lib/pricing-health'
|
||||
import type { PricingHealthRow } from '@/lib/pricing-health'
|
||||
import type { AdminCatalogModel, AdminUsageModelEntry, PriceSource } from '@/types/api'
|
||||
@@ -21,14 +26,37 @@ import type { AdminCatalogModel, AdminUsageModelEntry, PriceSource } from '@/typ
|
||||
* destructive badge (pricingHealthOrder) — the row an operator most needs
|
||||
* to act on (a served model billing at $0 because no pricing rule
|
||||
* matched) is always the first thing they see, before any manual sort.
|
||||
*
|
||||
* `loading`/`error` (verify-ui-states.md #3/#7 fix): this card had NO
|
||||
* loading or error input at all before — `spend.pricingHealthRanking` /
|
||||
* `catalog.modelsById` both start empty, so the very first Spend load, and
|
||||
* any failed Spend/catalog fetch, rendered the identical "No traffic in
|
||||
* this range." a genuinely empty fleet gets, with no way to tell the three
|
||||
* apart.
|
||||
*
|
||||
* `loaded` (verify-ui-states-2.md #2 fix): `hasData` below is "has a fetch
|
||||
* completed for the current range" (spend.pricingHealthLoaded), not
|
||||
* `rows.length > 0` — the 60s poll (stores/spend.ts's own refresh())
|
||||
* flips `loading` true on every tick, and a genuinely empty pricing-health
|
||||
* table used to flash a skeleton back on for each of those ticks. The
|
||||
* template below still checks `rows.length` directly for EmptyState vs.
|
||||
* the real table, the same way UserDetail.vue's own "Models used" table
|
||||
* does.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
models: AdminUsageModelEntry[]
|
||||
catalog: Map<string, AdminCatalogModel>
|
||||
loading: boolean
|
||||
loaded: boolean
|
||||
error: string
|
||||
/** Renders ErrorState's own "Retry" button — SpendPage.vue's own spend.fetchPricingHealthRanking. */
|
||||
onRetry?: () => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const rows = computed<PricingHealthRow[]>(() => pricingHealthOrder(joinPricingHealth(props.models, props.catalog)))
|
||||
|
||||
const state = computed(() => loadState({ loading: props.loading, hasData: props.loaded, error: props.error }))
|
||||
|
||||
const unpricedCount = computed<number>(() => rows.value.filter((r) => r.priceSource === 'unpriced').length)
|
||||
|
||||
/** SOURCE_BADGE_VARIANT mirrors lib/model-table-columns.ts's own priceCell badge treatment (WP-F, ModelCatalogTable.vue) so the two tables read consistently: destructive for unpriced (needs attention), secondary for a deliberate free model, outline for an explicit rule. */
|
||||
@@ -143,10 +171,17 @@ function pricingHealthCsv(): string {
|
||||
<CardDescription>Every model served in range, joined with its billing price source.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<DataTable :columns="columns" :data="rows" empty-message="No model served any traffic in this range." />
|
||||
<div class="flex justify-end">
|
||||
<CsvExportButton filename="pricing-health.csv" :build="pricingHealthCsv" />
|
||||
</div>
|
||||
<SkeletonTable v-if="state === 'skeleton'" :rows="5" :cols="columns.length" />
|
||||
<ErrorState v-else-if="state === 'error'" :message="error" :on-retry="onRetry" />
|
||||
<EmptyState v-else-if="rows.length === 0" title="No traffic in this range.">
|
||||
<TryLongerRangeButton />
|
||||
</EmptyState>
|
||||
<template v-else>
|
||||
<DataTable :columns="columns" :data="rows" empty-message="none" />
|
||||
<div class="flex justify-end">
|
||||
<CsvExportButton filename="pricing-health.csv" :build="pricingHealthCsv" />
|
||||
</div>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -9,12 +9,15 @@ import { computed, h, reactive, watch } from 'vue'
|
||||
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import EntityLink from '@/components/EntityLink.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import ModelChip from '@/components/ModelChip.vue'
|
||||
import ProviderRateBadge from '@/components/ProviderRateBadge.vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import SkeletonList from '@/components/SkeletonList.vue'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -24,6 +27,7 @@ import {
|
||||
} from '@/components/ui/card'
|
||||
import { useSearchQuery } from '@/composables/useSearchQuery'
|
||||
import { formatAgo, formatContextWindow, formatLatencyMs, formatUntil, refreshDetailLabel, refreshLabel, routableModelId } from '@/lib/format'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
import { isModelDegraded } from '@/lib/provider-rate'
|
||||
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
@@ -86,6 +90,19 @@ function aliasMatches(a: AdminAliasView): boolean {
|
||||
return a.alias.toLowerCase().includes(normalizedQuery.value) || a.target.toLowerCase().includes(normalizedQuery.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* providersLoadState (live-preview fix, same class of bug as
|
||||
* lib/access-matrix.ts's matrixColumnsState / TargetsView.vue's
|
||||
* targetsLoadState) — `overview` maps a not-yet-loaded dashboard.overview
|
||||
* straight to `[]` via `?? []` below, which the template used to render as
|
||||
* a confirmed "none" (no providers configured at all) before the 5s
|
||||
* dashboard poll's first response had even landed. `loading: true` mirrors
|
||||
* those same two call sites' own idiom: it is only ever consulted once
|
||||
* `hasData` (dashboard.overview !== null) is already false, so there is
|
||||
* nothing else to fall back to but a skeleton or the dashboard's own error.
|
||||
*/
|
||||
const providersLoadState = computed(() => loadState({ loading: true, hasData: overview.value !== null, error: dashboard.error }))
|
||||
|
||||
const filteredProviders = computed<AdminProviderView[]>(() => {
|
||||
const all = overview.value?.providers ?? []
|
||||
return hasQuery.value ? all.filter(providerMatches) : all
|
||||
@@ -340,11 +357,15 @@ const aliasEmptyMessage = computed(() =>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<Alert v-if="!models.latencyEnabled" variant="warn">
|
||||
<AlertDescription>
|
||||
Fleet p50/p95 are off for this deployment. Enable <code>admin.stats.latency</code> to see them.
|
||||
<AlertTitle>Latency statistics are off</AlertTitle>
|
||||
<AlertDescription class="flex flex-wrap items-center gap-2">
|
||||
<span>Enable <code>admin.stats.latency</code> in the middleware config to see fleet p50/p95.</span>
|
||||
<Button type="button" variant="outline" size="sm" @click="nav.goTo('config')">Open Config</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<p v-if="!overview?.providers.length" class="py-6 text-center text-sm text-muted-foreground">none</p>
|
||||
<SkeletonList v-if="providersLoadState === 'skeleton'" :rows="3" />
|
||||
<ErrorState v-else-if="providersLoadState === 'error'" :message="dashboard.error" :on-retry="dashboard.refresh" />
|
||||
<p v-else-if="!overview?.providers.length" class="py-6 text-center text-sm text-muted-foreground">none</p>
|
||||
<p
|
||||
v-else-if="hasQuery && filteredProviders.length === 0"
|
||||
class="py-6 text-center text-sm text-muted-foreground"
|
||||
@@ -485,14 +506,14 @@ const aliasEmptyMessage = computed(() =>
|
||||
</div>
|
||||
</dl>
|
||||
<div v-if="visibleModels(p).length" class="flex flex-wrap gap-1.5">
|
||||
<span v-for="m in visibleModels(p)" :key="m" class="inline-flex items-center gap-1">
|
||||
<span v-for="m in visibleModels(p)" :key="m" class="inline-flex min-w-0 max-w-full items-center gap-1">
|
||||
<ModelChip
|
||||
:id="routableModelId(p.name, m)"
|
||||
:context-tokens="modelMetaFor(p, m).contextTokens"
|
||||
:input-per-m-tok-usd="modelMetaFor(p, m).inputPerMTokUsd"
|
||||
:output-per-m-tok-usd="modelMetaFor(p, m).outputPerMTokUsd"
|
||||
/>
|
||||
<EntityLink :label="routableModelId(p.name, m)" kind="model" :id="routableModelId(p.name, m)" />
|
||||
<EntityLink :label="routableModelId(p.name, m)" kind="model" :id="routableModelId(p.name, m)" class="min-w-0" label-class="truncate" />
|
||||
<span
|
||||
v-if="modelMetaFor(p, m).contextTokens !== undefined"
|
||||
title="context window"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
/**
|
||||
* SkeletonChart is a fixed-aspect placeholder matching
|
||||
* TimeSeriesChart.vue's own container exactly (`h-72 w-full`) — every
|
||||
* chart in this panel (Spend's breakdown, Reliability's per-provider
|
||||
* error rate, BurnDownChart, UserDetail's per-user charts) renders inside
|
||||
* that same fixed height, so swapping this in for the first load never
|
||||
* shifts the layout once the real chart mounts.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-72 w-full">
|
||||
<Skeleton class="h-full w-full" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
/**
|
||||
* SkeletonKpiTile mirrors KpiTile.vue's own Card/CardContent structure
|
||||
* and spacing exactly — label, value, and delta lines at the same
|
||||
* heights — so a KPI grid swapping this in for real tiles on first load
|
||||
* causes no layout shift once the real values arrive.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardContent class="flex flex-col gap-1 px-4 py-3">
|
||||
<Skeleton class="h-3 w-20" />
|
||||
<Skeleton class="h-7 w-16" />
|
||||
<Skeleton class="h-3 w-28" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
/**
|
||||
* SkeletonList mirrors TopList.vue's own ranked-row shape (rank number,
|
||||
* label, right-aligned value) — `rows` pulsing rows, defaulting to 5 to
|
||||
* match "top 5" lists, so a list card swapping this in for its first load
|
||||
* causes no layout shift once real rows arrive.
|
||||
*/
|
||||
withDefaults(defineProps<{ rows?: number }>(), { rows: 5 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol class="flex flex-col gap-1.5">
|
||||
<li v-for="r in rows" :key="r" class="flex items-center justify-between gap-3">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Skeleton class="h-3 w-4 shrink-0" />
|
||||
<Skeleton class="h-4 w-full max-w-40" />
|
||||
</span>
|
||||
<Skeleton class="h-4 w-12 shrink-0" />
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
|
||||
/**
|
||||
* SkeletonTable is a placeholder shaped exactly like DataTable.vue's own
|
||||
* rendered table — the same Table/TableRow/TableHead/TableCell
|
||||
* primitives, `rows` x `cols` pulsing cells — so a table view swapping
|
||||
* this in for its first load causes no layout shift once real rows
|
||||
* arrive. `cols` defaults to 4, the panel's most common table width;
|
||||
* callers with a narrower or wider table pass their own column count.
|
||||
*/
|
||||
withDefaults(defineProps<{ rows?: number; cols?: number }>(), { rows: 5, cols: 4 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead v-for="c in cols" :key="c">
|
||||
<Skeleton class="h-4 w-16" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="r in rows" :key="r">
|
||||
<TableCell v-for="c in cols" :key="c">
|
||||
<Skeleton class="h-4 w-full max-w-32" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import { onUnmounted, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { copyText } from '@/lib/clipboard'
|
||||
import { useToastsStore } from '@/stores/toasts'
|
||||
|
||||
/**
|
||||
* SnippetBlock (redesign-plan.md section 3.4) is the one "here is text to
|
||||
@@ -22,9 +23,15 @@ const props = defineProps<{
|
||||
const preRef = useTemplateRef<HTMLElement>('pre')
|
||||
const state = ref<'idle' | 'copied' | 'selected' | 'failed'>('idle')
|
||||
let resetTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const toasts = useToastsStore()
|
||||
|
||||
async function onCopy(): Promise<void> {
|
||||
state.value = await copyText(props.text, preRef.value ?? undefined)
|
||||
// Only a FAILURE also gets a toast (states-plan.md item 3) — see
|
||||
// ModelChip.vue's own identical doc comment: 'copied'/'selected'
|
||||
// already have clear inline feedback (the button's own icon/label
|
||||
// swap right below), so a success toast would double-notify.
|
||||
if (state.value === 'failed') toasts.push({ kind: 'error', message: 'Could not copy the snippet.' })
|
||||
clearTimeout(resetTimer)
|
||||
resetTimer = setTimeout(() => {
|
||||
state.value = 'idle'
|
||||
|
||||
@@ -3,8 +3,11 @@ import { computed, ref, watch } from 'vue'
|
||||
|
||||
import ClampedRangeNotice from '@/components/ClampedRangeNotice.vue'
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import SkeletonTable from '@/components/SkeletonTable.vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { adminFetch } from '@/lib/api'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
import { dayOrMonthWindow, totalsUrl } from '@/lib/range'
|
||||
import { targetCallerColumns } from '@/lib/target-columns'
|
||||
import { useFiltersStore } from '@/stores/filters'
|
||||
@@ -47,7 +50,8 @@ const filters = useFiltersStore()
|
||||
|
||||
const rows = ref<AdminTotalsResponse['rows']>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
/** loadedOk is true only right after a SUCCESSFUL fetch for the CURRENT selection (even with zero rows — a genuine "no callers" answer is still data) — reset synchronously at the top of every load() call, not just on props.target changing, so a range change gets the identical "skeleton while this NEW selection's own fetch is in flight" treatment (states-plan.md item 1: a skeleton only for the current selection's own missing data, never stale data from the previous one). */
|
||||
const loadedOk = ref(false)
|
||||
const error = ref('')
|
||||
/** reqId guards against an out-of-order response overwriting a newer one (latest-request-wins, stores/catalog.ts's own convention) — a quick target switch, or a range change mid-flight, no longer risks showing a STALE target's callers under the new heading. */
|
||||
let reqId = 0
|
||||
@@ -55,6 +59,9 @@ let reqId = 0
|
||||
async function load(): Promise<void> {
|
||||
const requestId = ++reqId
|
||||
loading.value = true
|
||||
rows.value = []
|
||||
loadedOk.value = false
|
||||
error.value = ''
|
||||
try {
|
||||
const { window, span } = dayOrMonthWindow(filters.window, filters.span)
|
||||
const res = await adminFetch<AdminTotalsResponse>(
|
||||
@@ -62,15 +69,12 @@ async function load(): Promise<void> {
|
||||
)
|
||||
if (requestId !== reqId) return
|
||||
rows.value = res.rows
|
||||
error.value = ''
|
||||
loadedOk.value = true
|
||||
} catch (err) {
|
||||
if (requestId !== reqId) return
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
if (requestId === reqId) {
|
||||
loading.value = false
|
||||
loaded.value = true
|
||||
}
|
||||
if (requestId === reqId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,10 +86,8 @@ watch(
|
||||
|
||||
const columns = computed(() => targetCallerColumns(!props.target))
|
||||
|
||||
const emptyMessage = computed(() => {
|
||||
if (!loaded.value) return 'loading…'
|
||||
return 'no callers recorded in this window'
|
||||
})
|
||||
/** callersLoadState (lib/load-state.ts) replaces the old "loading…" text this view used to render inline as a fake DataTable empty-row — it no longer distinguished a still-loading fetch from one that had already FAILED (an initial-fetch failure rendered the identical "loading…" text forever, the same class of bug EventsView.vue's own P9 fix addressed). */
|
||||
const callersLoadState = computed(() => loadState({ loading: loading.value, hasData: loadedOk.value, error: error.value }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -96,8 +98,9 @@ const emptyMessage = computed(() => {
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<ClampedRangeNotice :window="filters.window" :span="filters.span" />
|
||||
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
|
||||
<DataTable :columns="columns" :data="rows" :empty-message="emptyMessage" />
|
||||
<SkeletonTable v-if="callersLoadState === 'skeleton'" :rows="5" :cols="columns.length" />
|
||||
<ErrorState v-else-if="callersLoadState === 'error'" :message="error" :on-retry="load" />
|
||||
<DataTable v-else :columns="columns" :data="rows" empty-message="no callers recorded in this window" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import SkeletonTable from '@/components/SkeletonTable.vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useSearchQuery } from '@/composables/useSearchQuery'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
import { composeTargetId, targetColumns } from '@/lib/target-columns'
|
||||
import type { TargetKind } from '@/lib/target-columns'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
@@ -62,6 +66,33 @@ function emptyMessage(configuredCount: number, filteredCount: number): string {
|
||||
const mcpEmptyMessage = computed(() => emptyMessage(targets.value?.mcpServers.length ?? 0, filteredMCPServers.value.length))
|
||||
const agentsEmptyMessage = computed(() => emptyMessage(targets.value?.agents.length ?? 0, filteredAgents.value.length))
|
||||
|
||||
/**
|
||||
* targetsLoadState (lib/load-state.ts) — GET /admin/api/targets used to
|
||||
* have NO loading/error handling at all here: `targets` mapped a
|
||||
* not-yet-loaded/errored dashboard.targets straight to `[]` (`?? []`
|
||||
* above), which DataTable then rendered as a confirmed, empty table —
|
||||
* exactly the same class of bug lib/access-matrix.ts's matrixColumnsState
|
||||
* fixed for the Access matrix (Task A). 'ready' once dashboard.targets
|
||||
* has loaded at least once, even if both lists are genuinely empty —
|
||||
* `noTargetsAtAll` below is the SEPARATE "loaded, but nothing configured"
|
||||
* check, same split as matrixColumnsState's own loaded-vs-empty
|
||||
* precedence.
|
||||
*/
|
||||
const targetsLoadState = computed(() => loadState({ loading: true, hasData: dashboard.targets !== null, error: dashboard.error }))
|
||||
|
||||
/**
|
||||
* noTargetsAtAll (states-plan.md item 2: "MCP & Agents: 'No MCP servers
|
||||
* or agents configured'") is true only once targets have genuinely
|
||||
* loaded with BOTH lists empty — read off the raw configured counts, not
|
||||
* the filtered ones, so an active search query never flips this true (a
|
||||
* query matching nothing is `mcpEmptyMessage`/`agentsEmptyMessage`'s own
|
||||
* "no targets match" case instead, a routine filter interaction, not a
|
||||
* fleet-configuration fact worth the same combined empty-state).
|
||||
*/
|
||||
const noTargetsAtAll = computed(
|
||||
() => targetsLoadState.value === 'ready' && (targets.value?.mcpServers.length ?? 0) === 0 && (targets.value?.agents.length ?? 0) === 0,
|
||||
)
|
||||
|
||||
// Two separate column-def sets (lib/target-columns.ts) — MCP servers and
|
||||
// Agents each close over their own fixed `kind` (see onSelect above) so a
|
||||
// name click composes the right "mcp/…"/"agent/…" id. Stateless ColumnDef
|
||||
@@ -75,30 +106,45 @@ const agentColumns = targetColumns(onSelect('agent'))
|
||||
<div class="flex flex-col gap-6">
|
||||
<SearchInput v-model="query" placeholder="Filter MCP servers or agents" class="max-w-sm" />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>MCP servers</CardTitle>
|
||||
<CardDescription>
|
||||
Configured MCP proxy targets, the groups allowed to reach them, and their request counters. Click a name to see its
|
||||
callers.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<Card v-if="noTargetsAtAll">
|
||||
<CardContent>
|
||||
<DataTable :columns="mcpColumns" :data="filteredMCPServers" :empty-message="mcpEmptyMessage" />
|
||||
<EmptyState
|
||||
title="No MCP servers or agents configured."
|
||||
description="Add an mcpServers or agents entry to the middleware config to see them here."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agents</CardTitle>
|
||||
<CardDescription>
|
||||
Configured A2A agent proxy targets, the groups allowed to reach them, and their request counters. Click a name to
|
||||
see its callers.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable :columns="agentColumns" :data="filteredAgents" :empty-message="agentsEmptyMessage" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<template v-else>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>MCP servers</CardTitle>
|
||||
<CardDescription>
|
||||
Configured MCP proxy targets, the groups allowed to reach them, and their request counters. Click a name to see
|
||||
its callers.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SkeletonTable v-if="targetsLoadState === 'skeleton'" :rows="3" :cols="mcpColumns.length" />
|
||||
<ErrorState v-else-if="targetsLoadState === 'error'" :message="dashboard.error" />
|
||||
<DataTable v-else :columns="mcpColumns" :data="filteredMCPServers" :empty-message="mcpEmptyMessage" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agents</CardTitle>
|
||||
<CardDescription>
|
||||
Configured A2A agent proxy targets, the groups allowed to reach them, and their request counters. Click a name to
|
||||
see its callers.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SkeletonTable v-if="targetsLoadState === 'skeleton'" :rows="3" :cols="agentColumns.length" />
|
||||
<ErrorState v-else-if="targetsLoadState === 'error'" :message="dashboard.error" />
|
||||
<DataTable v-else :columns="agentColumns" :data="filteredAgents" :empty-message="agentsEmptyMessage" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { faCheck, faCircleInfo, faTriangleExclamation, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Toast, ToastKind } from '@/stores/toasts'
|
||||
import { useToastsStore } from '@/stores/toasts'
|
||||
|
||||
/**
|
||||
* ToastViewport (states-plan.md item 3) is the panel's one toast stack —
|
||||
* mounted ONCE in App.vue, reading stores/toasts.ts. Bottom-right, a
|
||||
* fixed width, on desktop; a full-width bottom sheet on mobile, inset by
|
||||
* the device's own safe area (the `pb-[calc(env(safe-area-inset-bottom)…
|
||||
* )]` arbitrary value below, not an inline style — this panel styles
|
||||
* with Tailwind only).
|
||||
*
|
||||
* TWO always-mounted live regions (verify-ui-states.md #5/#6 fix,
|
||||
* replacing the earlier per-toast `role="status"`/`role="alert"`
|
||||
* approach): a live region must exist in the DOM BEFORE its content
|
||||
* changes to be reliably announced — a `role="status"`/`role="alert"`
|
||||
* node inserted already filled with text is often missed entirely
|
||||
* (NVDA/JAWS, and VoiceOver in some cases). `politeRegion` below
|
||||
* (`aria-live="polite"`) is present from this component's first render
|
||||
* and holds success/info toasts; `assertiveRegion` (`aria-live="assertive"`)
|
||||
* is likewise always present and holds error toasts — toasts render AS
|
||||
* CHILDREN into whichever already-mounted region matches their kind,
|
||||
* so only their own text content changes, which every live-region
|
||||
* implementation reliably announces. `motion-reduce:transition-none` on
|
||||
* both TransitionGroups honors prefers-reduced-motion instead of
|
||||
* animating regardless.
|
||||
*/
|
||||
const toasts = useToastsStore()
|
||||
|
||||
const ICON: Record<ToastKind, IconDefinition> = {
|
||||
success: faCheck,
|
||||
error: faTriangleExclamation,
|
||||
info: faCircleInfo,
|
||||
}
|
||||
|
||||
const ICON_CLASS: Record<ToastKind, string> = {
|
||||
success: 'text-chart-requests',
|
||||
error: 'text-destructive',
|
||||
info: 'text-muted-foreground',
|
||||
}
|
||||
|
||||
/** politeToasts/assertiveToasts split the store's single ordered list by kind — success/info are politely announced, error assertively, matching each region's own aria-live level. */
|
||||
const politeToasts = computed<Toast[]>(() => toasts.toasts.filter((t) => t.kind !== 'error'))
|
||||
const assertiveToasts = computed<Toast[]>(() => toasts.toasts.filter((t) => t.kind === 'error'))
|
||||
|
||||
function onDismiss(toast: Toast): void {
|
||||
toasts.dismiss(toast.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col-reverse gap-2 p-4 pb-[calc(env(safe-area-inset-bottom)+1rem)] sm:inset-x-auto sm:right-4 sm:bottom-4 sm:w-96"
|
||||
>
|
||||
<div aria-live="assertive" class="flex flex-col-reverse gap-2">
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
class="flex flex-col-reverse gap-2"
|
||||
enter-active-class="transition duration-150 ease-out motion-reduce:transition-none"
|
||||
enter-from-class="translate-y-2 opacity-0"
|
||||
leave-active-class="transition duration-150 ease-in motion-reduce:transition-none"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-for="toast in assertiveToasts"
|
||||
:key="toast.id"
|
||||
class="pointer-events-auto flex items-start gap-2 rounded-lg border bg-popover px-3 py-2.5 text-popover-foreground shadow-lg"
|
||||
>
|
||||
<FontAwesomeIcon :icon="ICON[toast.kind]" class="mt-0.5 size-3.5 shrink-0" :class="ICON_CLASS[toast.kind]" aria-hidden="true" />
|
||||
<p class="min-w-0 flex-1 text-sm">{{ toast.message }}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="-mt-1 -mr-1 size-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
:aria-label="`Dismiss: ${toast.message}`"
|
||||
@click="onDismiss(toast)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faXmark" class="size-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
<div aria-live="polite" class="flex flex-col-reverse gap-2">
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
class="flex flex-col-reverse gap-2"
|
||||
enter-active-class="transition duration-150 ease-out motion-reduce:transition-none"
|
||||
enter-from-class="translate-y-2 opacity-0"
|
||||
leave-active-class="transition duration-150 ease-in motion-reduce:transition-none"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-for="toast in politeToasts"
|
||||
:key="toast.id"
|
||||
class="pointer-events-auto flex items-start gap-2 rounded-lg border bg-popover px-3 py-2.5 text-popover-foreground shadow-lg"
|
||||
>
|
||||
<FontAwesomeIcon :icon="ICON[toast.kind]" class="mt-0.5 size-3.5 shrink-0" :class="ICON_CLASS[toast.kind]" aria-hidden="true" />
|
||||
<p class="min-w-0 flex-1 text-sm">{{ toast.message }}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="-mt-1 -mr-1 size-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
:aria-label="`Dismiss: ${toast.message}`"
|
||||
@click="onDismiss(toast)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faXmark" class="size-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import EntityLink from '@/components/EntityLink.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import SkeletonList from '@/components/SkeletonList.vue'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
|
||||
/** One TopList.vue row — an entity id/label, its formatted value, and which EntityLink.vue target it opens. */
|
||||
export interface TopListItem {
|
||||
@@ -19,28 +25,82 @@ export interface TopListItem {
|
||||
* so clicking a user opens Consumers pre-filtered to them, a model opens
|
||||
* Models — the SAME "jump to detail" affordance every table's id column
|
||||
* (usage-columns.ts, model-table-columns.ts) offers, reused here rather
|
||||
* than a plain unlinked list.
|
||||
* than a plain unlinked list. The optional `controls` slot renders below
|
||||
* the title (e.g. HomePage.vue's top-users Cost/Requests/Tokens Tabs
|
||||
* switch, lib/top-users.ts) — omitted by callers with no mode switch of
|
||||
* their own, like the plain top-models card, so this stays one shared
|
||||
* component rather than a near-duplicate per card.
|
||||
*
|
||||
* States (states-plan.md item 1/3, verify-ui-states.md #1/#3/#4,
|
||||
* verify-ui-states-2.md #2) are decided via lib/load-state.ts's own
|
||||
* loadState off `loading`/`error`/`loaded` — NOT a bare
|
||||
* `loading && items.length === 0` check, and NOT `items.length > 0`
|
||||
* either: `hasData` here means "a fetch has completed for the CURRENT
|
||||
* selection" (the `loaded` prop, e.g. HomePage.vue's own per-mode
|
||||
* topUsersCache presence), not "there happen to be rows right now". A
|
||||
* caller's genuinely empty, already-settled selection must keep reading
|
||||
* as empty across a background poll's own `loading` flips (skeleton is
|
||||
* for a selection's FIRST fetch only) — using `items.length` for
|
||||
* `hasData` used to flip a settled EmptyState back to a skeleton on
|
||||
* every poll tick for exactly that case. Once `state` is not 'skeleton'
|
||||
* or 'error', the template below decides EmptyState vs. the real list by
|
||||
* checking `items.length` directly, the same way UserDetail.vue's own
|
||||
* "Models used" table does.
|
||||
*/
|
||||
defineProps<{
|
||||
title: string
|
||||
items: TopListItem[]
|
||||
/** Shown in place of the list when `items` is empty — e.g. "no traffic in this range yet". */
|
||||
emptyText: string
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
items: TopListItem[]
|
||||
/** Shown in place of the list when `items` is empty (and not loading, not erroring) — e.g. "No traffic in this range." */
|
||||
emptyText: string
|
||||
loading?: boolean
|
||||
/**
|
||||
* True once a fetch has completed for the CURRENT selection (mode/
|
||||
* range), even if it resolved with zero items — the caller's own
|
||||
* per-selection flag (verify-ui-states-2.md #2), reset whenever the
|
||||
* caller's selection genuinely changes. Distinct from `items.length >
|
||||
* 0`: see this component's own States doc comment above.
|
||||
*/
|
||||
loaded?: boolean
|
||||
/** The current fetch's error message, '' (default) when there is none — mirrors lib/load-state.ts's own LoadStateInput.error. A background refresh's failure with `items` already populated never reaches ErrorState (loadState's 'ready' precedence) — stores/toasts.ts covers that case instead. */
|
||||
error?: string
|
||||
/** Renders a "Retry" button on ErrorState — the caller's own refetch action. */
|
||||
onRetry?: () => void | Promise<void>
|
||||
/**
|
||||
* A distinct, higher-precedence notice (e.g. HomePage.vue's top-users
|
||||
* "too many configured users for this range", lib/top-users.ts's
|
||||
* topUsersCapExceeded) that takes over the card's content entirely —
|
||||
* it names a KNOWN reason no data will load until the reader acts
|
||||
* (narrow the range), not a transient loading/error/empty state, so it
|
||||
* is checked before all three.
|
||||
*/
|
||||
notice?: string
|
||||
noticeDescription?: string
|
||||
}>(),
|
||||
{ loading: false, loaded: false, error: '' },
|
||||
)
|
||||
|
||||
const state = computed(() => loadState({ loading: props.loading, hasData: props.loaded, error: props.error ?? '' }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ title }}</CardTitle>
|
||||
<slot name="controls" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p v-if="items.length === 0" class="text-sm text-muted-foreground">{{ emptyText }}</p>
|
||||
<EmptyState v-if="notice" :title="notice" :description="noticeDescription" />
|
||||
<SkeletonList v-else-if="state === 'skeleton'" :rows="5" />
|
||||
<ErrorState v-else-if="state === 'error'" :message="error ?? ''" :on-retry="onRetry" />
|
||||
<EmptyState v-else-if="items.length === 0" :title="emptyText">
|
||||
<slot name="empty-action" />
|
||||
</EmptyState>
|
||||
<ol v-else class="flex flex-col gap-1.5">
|
||||
<li v-for="(item, i) in items" :key="item.id" class="flex items-center justify-between gap-3 text-sm">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span class="w-4 shrink-0 text-right text-xs text-muted-foreground">{{ i + 1 }}</span>
|
||||
<EntityLink :label="item.label" :kind="item.kind" :id="item.id" class="min-w-0 truncate" />
|
||||
<EntityLink :label="item.label" :kind="item.kind" :id="item.id" class="min-w-0" label-class="truncate" />
|
||||
</span>
|
||||
<span class="shrink-0 font-medium tabular-nums">{{ item.value }}</span>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RANGE_KEYS } from '@/lib/range'
|
||||
import { useFiltersStore } from '@/stores/filters'
|
||||
|
||||
/**
|
||||
* TryLongerRangeButton is the shared action every "No traffic in this
|
||||
* range" empty state offers (states-plan.md item 2: Spend/Models/Home
|
||||
* lists) — steps the global range to the NEXT LONGER preset in
|
||||
* RANGE_KEYS (verify-ui-states.md #7 fix), via stores/filters.ts, rather
|
||||
* than each empty state re-deriving its own inline click handler
|
||||
* (vue.md: "if you've written it twice, you owe an abstraction" — this
|
||||
* one appears on Home's two TopLists, the Spend page's Attribution
|
||||
* drilldown, and PricingHealthTable).
|
||||
*
|
||||
* Previously this always jumped straight to a fixed '30d' — on 3mo/6mo/
|
||||
* 12mo (lib/range.ts's RANGE_KEYS, already LONGER than 30d) that
|
||||
* SHORTENED the range instead of lengthening it, and did nothing at all
|
||||
* when already on 30d itself. Stepping to `RANGE_KEYS[currentIndex + 1]`
|
||||
* is always strictly longer than the current range, and the button hides
|
||||
* itself entirely at '12mo' (the longest preset — there is nothing
|
||||
* longer left to try).
|
||||
*/
|
||||
const filters = useFiltersStore()
|
||||
|
||||
const nextLongerRange = computed<string | null>(() => {
|
||||
const index = RANGE_KEYS.indexOf(filters.range)
|
||||
const next = RANGE_KEYS[index + 1]
|
||||
return next ?? null
|
||||
})
|
||||
|
||||
function onClick(): void {
|
||||
if (nextLongerRange.value) filters.setFilters({ range: nextLongerRange.value as (typeof RANGE_KEYS)[number] })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button v-if="nextLongerRange" type="button" variant="outline" size="sm" @click="onClick">Try a longer range</Button>
|
||||
</template>
|
||||
@@ -4,9 +4,14 @@ import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import ClampedRangeNotice from '@/components/ClampedRangeNotice.vue'
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import ModelChip from '@/components/ModelChip.vue'
|
||||
import SkeletonChart from '@/components/SkeletonChart.vue'
|
||||
import SkeletonTable from '@/components/SkeletonTable.vue'
|
||||
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import TryLongerRangeButton from '@/components/TryLongerRangeButton.vue'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -15,11 +20,13 @@ import UsageBar from '@/components/UsageBar.vue'
|
||||
import { useNow } from '@/composables/useNow'
|
||||
import { runOutDate as computeRunOutDate } from '@/lib/burndown'
|
||||
import { formatBucketLabel, formatCost, formatExactInt, formatLimits, formatTimestamp } from '@/lib/format'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
import { BUDGET_RATIO_LABEL, budgetRatios } from '@/lib/usage-bars'
|
||||
import { useConsumersStore } from '@/stores/consumers'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useEventsStore } from '@/stores/events'
|
||||
import { useFiltersStore } from '@/stores/filters'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
|
||||
/**
|
||||
* UserDetail (redesign-plan.md section 3.4) is the Consumers page's
|
||||
@@ -35,6 +42,7 @@ const dashboard = useDashboardStore()
|
||||
const consumers = useConsumersStore()
|
||||
const events = useEventsStore()
|
||||
const filters = useFiltersStore()
|
||||
const nav = useNavStore()
|
||||
const { now, start: startClock, stop: stopClock } = useNow()
|
||||
onMounted(startClock)
|
||||
onUnmounted(stopClock)
|
||||
@@ -47,12 +55,19 @@ const consumerUser = computed(() => consumers.data?.users.find((u) => u.name ===
|
||||
|
||||
const featuresUserModelStats = computed(() => dashboard.overview?.features.userModelStats ?? false)
|
||||
|
||||
function loadDetail(): void {
|
||||
void consumers.fetchUserDetail(props.userId, filters.window, filters.span, featuresUserModelStats.value)
|
||||
/**
|
||||
* loadDetail (verify-ui-states-2.md #6 fix) returns fetchUserDetail's own
|
||||
* promise rather than `void`-ing it — ErrorState.onRetryClick (ErrorState.
|
||||
* vue) awaits `onRetry()` to drive its own local `retrying` spinner/disable
|
||||
* state; with this returning `void`, that await resolved immediately and
|
||||
* the Retry button showed no in-flight feedback at all.
|
||||
*/
|
||||
function loadDetail(): Promise<void> {
|
||||
return consumers.fetchUserDetail(props.userId, filters.window, filters.span, featuresUserModelStats.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDetail()
|
||||
void loadDetail()
|
||||
void events.refresh()
|
||||
})
|
||||
// featuresUserModelStats is included alongside userId/range: on a deep
|
||||
@@ -87,6 +102,24 @@ const costDatasets = computed(() => [
|
||||
{ label: 'Cost', data: (detail.value?.costSeries?.series[0]?.points ?? []).map((v) => v), color: '--chart-cost' },
|
||||
])
|
||||
|
||||
/**
|
||||
* reqState/costState/modelState (verify-ui-states.md #4 fix) replace the
|
||||
* old bare `detail?.loading` skeleton check, which flipped a skeleton
|
||||
* back on over already-valid, already-rendered data in three cases:
|
||||
* (a) re-opening a cached user (consumers.detail[userId] keeps its
|
||||
* series, but loading flips true again on every fetchUserDetail call);
|
||||
* (b) the featuresUserModelStats watch's own follow-up refetch once the
|
||||
* overview lands; (c) a Retry click. `hasData` (reqSeries/costSeries/
|
||||
* modelRows non-null/non-empty) now takes precedence over `loading` —
|
||||
* lib/load-state.ts's own 'ready' precedence — so a background refetch
|
||||
* with data already on screen never shows a skeleton, and each chart/
|
||||
* table reads only its OWN error field (stores/consumers.ts's
|
||||
* UserDetailState split), so an unrelated section's failure never
|
||||
* blanks a perfectly valid one.
|
||||
*/
|
||||
const reqState = computed(() => loadState({ loading: detail.value?.loading ?? false, hasData: detail.value?.reqSeries != null, error: detail.value?.reqError ?? '' }))
|
||||
const costState = computed(() => loadState({ loading: detail.value?.loading ?? false, hasData: detail.value?.costSeries != null, error: detail.value?.costError ?? '' }))
|
||||
|
||||
/** showUserModelHint mirrors AttributionDrilldown.vue's own convention (Spend page): the hint shows when the client's own feature flag reads off, OR the usermodel call itself came back 404 (stores/consumers.ts's modelTotalsUnavailable) — the second case catches a stale/not-yet-loaded overview on a deep link or reload, where featuresUserModelStats can read true for a moment while the server still says otherwise. */
|
||||
const showUserModelHint = computed(() => !featuresUserModelStats.value || detail.value?.modelTotalsUnavailable === true)
|
||||
|
||||
@@ -96,6 +129,21 @@ const modelRows = computed(() => {
|
||||
return [...rows].sort((a, b) => (b.values.cost ?? b.values.req ?? 0) - (a.values.cost ?? a.values.req ?? 0))
|
||||
})
|
||||
|
||||
/**
|
||||
* modelState mirrors reqState/costState above for the "Models used" table
|
||||
* — only reachable once showUserModelHint is false (the template checks
|
||||
* that Alert first), so 'error'/'skeleton' here are always a genuine fetch
|
||||
* problem, not the feature simply being off. `hasData` (verify-ui-states-2.
|
||||
* md #2 fix) is `detail.modelTotals != null`, not `modelRows.length > 0` —
|
||||
* a user with genuinely zero per-model rows used to flash a skeleton back
|
||||
* on over the "No traffic in this range." EmptyState on a re-open, a
|
||||
* Retry, or the featuresUserModelStats follow-up refetch (every one of
|
||||
* which flips `detail.loading` true again). The template's own
|
||||
* `!modelRows.length` check (below) still routes an empty result to
|
||||
* EmptyState.
|
||||
*/
|
||||
const modelState = computed(() => loadState({ loading: detail.value?.loading ?? false, hasData: detail.value?.modelTotals != null, error: detail.value?.modelError ?? '' }))
|
||||
|
||||
/** recentErrors filters the shared Events store (WP-F, read-only import) down to this user — the newest 10, matching the store's own newest-first order. */
|
||||
const recentErrors = computed(() => events.events.filter((e) => e.user === props.userId).slice(0, 10))
|
||||
|
||||
@@ -131,7 +179,7 @@ const runOutDate = computed(() => {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<CardTitle class="font-mono">{{ userId }}</CardTitle>
|
||||
<CardTitle class="min-w-0 break-all font-mono">{{ userId }}</CardTitle>
|
||||
<Badge v-if="consumerUser?.admin" variant="destructive">admin</Badge>
|
||||
<Badge v-if="consumerUser?.source" variant="secondary">{{ consumerUser.source }}</Badge>
|
||||
</div>
|
||||
@@ -184,8 +232,8 @@ const runOutDate = computed(() => {
|
||||
<CardTitle>Requests over time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p v-if="detail?.loading" class="text-sm text-muted-foreground">loading…</p>
|
||||
<p v-else-if="detail?.error" class="text-sm text-destructive">{{ detail.error }}</p>
|
||||
<SkeletonChart v-if="reqState === 'skeleton'" />
|
||||
<ErrorState v-else-if="reqState === 'error'" :message="detail?.reqError ?? ''" :on-retry="loadDetail" />
|
||||
<TimeSeriesChart v-else :labels="reqLabels" :datasets="reqDatasets" :ariaLabel="`Requests over time for ${userId}`" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -195,8 +243,8 @@ const runOutDate = computed(() => {
|
||||
<CardTitle>Cost over time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p v-if="detail?.loading" class="text-sm text-muted-foreground">loading…</p>
|
||||
<p v-else-if="detail?.error" class="text-sm text-destructive">{{ detail.error }}</p>
|
||||
<SkeletonChart v-if="costState === 'skeleton'" />
|
||||
<ErrorState v-else-if="costState === 'error'" :message="detail?.costError ?? ''" :on-retry="loadDetail" />
|
||||
<TimeSeriesChart v-else :labels="costLabels" :datasets="costDatasets" :value-formatter="formatCost" :ariaLabel="`Cost over time for ${userId}`" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -208,9 +256,18 @@ const runOutDate = computed(() => {
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<ClampedRangeNotice :window="filters.window" :span="filters.span" />
|
||||
<Alert v-if="showUserModelHint" variant="warn">
|
||||
<AlertDescription>Enable admin.stats.userModel in the plugin config to see a per-model breakdown for this user.</AlertDescription>
|
||||
<AlertTitle>Per-user-model statistics are off</AlertTitle>
|
||||
<AlertDescription class="flex flex-wrap items-center gap-2">
|
||||
<span>Enable <code class="font-mono text-xs">admin.stats.userModel</code> in the middleware config to see a per-model breakdown for this user.</span>
|
||||
<Button type="button" variant="outline" size="sm" @click="nav.goTo('config')">Open Config</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Table v-else-if="modelRows.length">
|
||||
<SkeletonTable v-else-if="modelState === 'skeleton'" :rows="3" :cols="3" />
|
||||
<ErrorState v-else-if="modelState === 'error'" :message="detail?.modelError ?? ''" :on-retry="loadDetail" />
|
||||
<EmptyState v-else-if="!modelRows.length" title="No traffic in this range.">
|
||||
<TryLongerRangeButton />
|
||||
</EmptyState>
|
||||
<Table v-else>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Model</TableHead>
|
||||
@@ -226,7 +283,6 @@ const runOutDate = computed(() => {
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<p v-else class="py-6 text-center text-sm text-muted-foreground">no model usage in this range</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -257,7 +313,14 @@ const runOutDate = computed(() => {
|
||||
<div>
|
||||
<p class="mb-1.5 text-xs text-muted-foreground">Providers</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Badge v-for="name in consumerUser?.providers ?? []" :key="name" as="span" variant="secondary" class="font-mono font-normal">
|
||||
<Badge
|
||||
v-for="name in consumerUser?.providers ?? []"
|
||||
:key="name"
|
||||
as="span"
|
||||
variant="secondary"
|
||||
class="max-w-full truncate font-mono font-normal"
|
||||
:title="name"
|
||||
>
|
||||
{{ name }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { API_KEY_PLACEHOLDER, generateApiKey, isValidUserName, userJsonLine, validatePersonalGrant } from '@/lib/snippets'
|
||||
import { useToastsStore } from '@/stores/toasts'
|
||||
|
||||
/**
|
||||
* UserForm (ChangeHelper, redesign-plan.md section 3.4) builds a whole new
|
||||
@@ -28,8 +29,26 @@ const providersRaw = ref('')
|
||||
const modelsRaw = ref('')
|
||||
const admin = ref(false)
|
||||
|
||||
const toasts = useToastsStore()
|
||||
|
||||
/**
|
||||
* generate (states-plan.md item 3) confirms a fresh key via toast — unlike
|
||||
* the copy-to-clipboard actions elsewhere in this panel, nothing else on
|
||||
* screen visibly draws the eye to the API-key input changing (it is one
|
||||
* field among many, above the fold but easy to miss), so this one gets a
|
||||
* success toast, not just the failure-only treatment ModelChip/
|
||||
* SnippetBlock/CsvExportButton use. generateApiKey (lib/snippets.ts) reads
|
||||
* crypto.getRandomValues, which can theoretically throw in a context with
|
||||
* no Web Crypto — caught defensively rather than left to bubble into an
|
||||
* unhandled click-handler rejection.
|
||||
*/
|
||||
function generate(): void {
|
||||
apiKey.value = generateApiKey()
|
||||
try {
|
||||
apiKey.value = generateApiKey()
|
||||
toasts.push({ kind: 'success', message: 'Generated a new API key.' })
|
||||
} catch (err) {
|
||||
toasts.push({ kind: 'error', message: err instanceof Error ? `Could not generate an API key: ${err.message}` : 'Could not generate an API key.' })
|
||||
}
|
||||
}
|
||||
|
||||
function parseList(raw: string): string[] {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Skeleton is shadcn-vue's own loading-placeholder primitive
|
||||
* (shadcn-vue.com/docs/components/skeleton): a plain pulsing block, sized
|
||||
* by whatever `class` the caller passes (e.g. `h-4 w-24`). Every composed
|
||||
* skeleton in this panel (SkeletonKpiTile, SkeletonTable, SkeletonChart,
|
||||
* SkeletonList) is built from one or more of these, each sized to match
|
||||
* the final layout's own dimensions so data arriving causes no layout
|
||||
* shift (states-plan.md item 1). `motion-reduce:animate-none` honors
|
||||
* prefers-reduced-motion instead of pulsing regardless.
|
||||
*/
|
||||
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="skeleton" :class="cn('animate-pulse rounded-md bg-muted motion-reduce:animate-none', props.class)" />
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Skeleton } from './Skeleton.vue'
|
||||
@@ -16,7 +16,16 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
<TabsTrigger
|
||||
data-slot="tabs-trigger"
|
||||
:class="cn(
|
||||
'gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg:not([class*=size-])]:size-4 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
// group-data-horizontal/tabs:shrink-0/flex-none: every trigger keeps
|
||||
// its natural (content) width in a horizontal strip instead of
|
||||
// flex-1-shrinking to fit — TabsList (index.ts) is the thing that
|
||||
// scrolls, not the individual triggers squeezing into an unreadable
|
||||
// sliver. Vertical orientation is unaffected (still
|
||||
// group-data-vertical/tabs:w-full below).
|
||||
// group-data-horizontal/tabs:flex-none alone covers both
|
||||
// flex-grow:0 and flex-shrink:0 (P3 lows fix, verify-ui-states.md
|
||||
// #11) — the separate `shrink-0` utility next to it was redundant.
|
||||
'gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg:not([class*=size-])]:size-4 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-horizontal/tabs:flex-none group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
|
||||
'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
|
||||
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
|
||||
|
||||
@@ -7,7 +7,34 @@ export { default as TabsList } from './TabsList.vue'
|
||||
export { default as TabsTrigger } from './TabsTrigger.vue'
|
||||
|
||||
export const tabsListVariants = cva(
|
||||
'rounded-lg p-0.75 group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list inline-flex w-fit items-center justify-center text-muted-foreground group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col',
|
||||
// Horizontal orientation (the only one this app actually uses, in every
|
||||
// tab strip switching between pages/kinds/forms) is capped to the
|
||||
// available width and made horizontally scrollable instead of letting
|
||||
// `w-fit` push it wider than its container — every Card (ui/card)
|
||||
// clips overflow with `overflow-hidden` by design (rounded corners), so
|
||||
// an uncapped TabsList wider than its Card doesn't spill onto the page
|
||||
// (confirmed via live audit: zero page-level scrollWidth overflow
|
||||
// anywhere) — it just gets silently CLIPPED with no scrollbar and no
|
||||
// way to reach the hidden triggers. That silent clipping, not page
|
||||
// overflow, is what broke "switching tabs on mobile" (measured live:
|
||||
// the 5-trigger Config change-helper strip loses its last ~1.5 triggers
|
||||
// under its Card at 360-390px). `justify-start` overrides the default
|
||||
// centering only for this case: centering an overflowing flex row can
|
||||
// otherwise leave its first item unreachable by scroll in some browsers
|
||||
// (the well-known justify-content/overflow interaction) — reka-ui's own
|
||||
// roving-tabindex keyboard handling on TabsTrigger is untouched by any
|
||||
// of this, so arrow-key/Home/End navigation still works.
|
||||
//
|
||||
// `overflow-x-auto` alone (verify-ui-states.md #2) makes the computed
|
||||
// `overflow-y` resolve to `auto` too (the CSS `overflow` shorthand
|
||||
// behavior when only one axis is set explicitly) — combined with
|
||||
// TabsTrigger's own active-indicator pseudo-element extending 5px below
|
||||
// the 32px-tall list (`after:bottom-[-5px]`), that produced a real,
|
||||
// always-visible vertical scrollbar on every TabsList on any platform
|
||||
// that shows classic (non-overlay) scrollbars. `overflow-y-hidden`
|
||||
// clips that harmless 5px indicator overflow without touching the
|
||||
// horizontal scroll this rule exists for.
|
||||
'rounded-lg p-0.75 group-data-horizontal/tabs:h-8 group-data-horizontal/tabs:max-w-full group-data-horizontal/tabs:justify-start group-data-horizontal/tabs:overflow-x-auto group-data-horizontal/tabs:overflow-y-hidden data-[variant=line]:rounded-none group/tabs-list inline-flex w-fit items-center justify-center text-muted-foreground group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildAccessMatrix, providerAccessCell, targetAccessCell } from './access-matrix'
|
||||
import {
|
||||
buildAccessMatrix,
|
||||
MATRIX_KIND_LABEL,
|
||||
MATRIX_KINDS,
|
||||
matrixColumnsState,
|
||||
parseMatrixKind,
|
||||
providerAccessCell,
|
||||
targetAccessCell,
|
||||
} from './access-matrix'
|
||||
import type { AdminConsumerGroup, AdminTargetView } from '@/types/api'
|
||||
|
||||
function group(overrides: Partial<AdminConsumerGroup> = {}): AdminConsumerGroup {
|
||||
@@ -99,3 +107,58 @@ describe('buildAccessMatrix', () => {
|
||||
expect(buildAccessMatrix([], ['anthropic'], [], [])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('MATRIX_KINDS', () => {
|
||||
it('orders the three kinds models, mcp, agents', () => {
|
||||
expect(MATRIX_KINDS).toEqual(['models', 'mcp', 'agents'])
|
||||
})
|
||||
|
||||
it('labels every kind', () => {
|
||||
expect(MATRIX_KIND_LABEL).toEqual({ models: 'Models', mcp: 'MCP servers', agents: 'Agents' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseMatrixKind', () => {
|
||||
it('parses each valid kind', () => {
|
||||
expect(parseMatrixKind('models')).toBe('models')
|
||||
expect(parseMatrixKind('mcp')).toBe('mcp')
|
||||
expect(parseMatrixKind('agents')).toBe('agents')
|
||||
})
|
||||
|
||||
it('falls back to models for an unknown value', () => {
|
||||
expect(parseMatrixKind('bogus')).toBe('models')
|
||||
})
|
||||
|
||||
it('falls back to models when undefined', () => {
|
||||
expect(parseMatrixKind(undefined)).toBe('models')
|
||||
})
|
||||
})
|
||||
|
||||
describe('matrixColumnsState', () => {
|
||||
it('is "loading" when the source has not loaded yet and there is no dashboard error', () => {
|
||||
expect(matrixColumnsState(false, 0, '')).toBe('loading')
|
||||
})
|
||||
|
||||
it('is "error" when the source has not loaded and dashboard.error is set', () => {
|
||||
expect(matrixColumnsState(false, 0, 'overview: fetch failed')).toBe('error')
|
||||
})
|
||||
|
||||
// Bug this helper fixes: a not-yet-loaded source maps to 0 columns via
|
||||
// `?? []`, indistinguishable from a genuinely empty, loaded source by
|
||||
// column count alone — so columnCount must never override sourceLoaded.
|
||||
it('is "loading", not "empty", when the source has not loaded even though columnCount is 0', () => {
|
||||
expect(matrixColumnsState(false, 0, '')).not.toBe('empty')
|
||||
})
|
||||
|
||||
it('is "empty" when the source has loaded with zero columns', () => {
|
||||
expect(matrixColumnsState(true, 0, '')).toBe('empty')
|
||||
})
|
||||
|
||||
it('is "empty" when the source has loaded with zero columns even if a stale dashboard.error lingers', () => {
|
||||
expect(matrixColumnsState(true, 0, 'usage: fetch failed')).toBe('empty')
|
||||
})
|
||||
|
||||
it('is "ready" when the source has loaded with at least one column', () => {
|
||||
expect(matrixColumnsState(true, 3, '')).toBe('ready')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,6 +63,35 @@ export function targetAccessCell(group: AdminConsumerGroup, target: Pick<AdminTa
|
||||
return { allowed: target.access.includes(group.name) }
|
||||
}
|
||||
|
||||
/**
|
||||
* MatrixKind is AccessMatrix.vue's own view switch: which one column
|
||||
* group the table currently renders (P4 three-view split — the old
|
||||
* matrix mixed providers, MCP servers, and agents in one wide table,
|
||||
* which was hard to scan). `'models'` is the default.
|
||||
*/
|
||||
export type MatrixKind = 'models' | 'mcp' | 'agents'
|
||||
|
||||
/** MATRIX_KINDS is the ordered list AccessMatrix.vue's Tabs render from — this array's order IS the tab order. */
|
||||
export const MATRIX_KINDS: MatrixKind[] = ['models', 'mcp', 'agents']
|
||||
|
||||
/** MATRIX_KIND_LABEL is each kind's tab/column-group label. */
|
||||
export const MATRIX_KIND_LABEL: Record<MatrixKind, string> = {
|
||||
models: 'Models',
|
||||
mcp: 'MCP servers',
|
||||
agents: 'Agents',
|
||||
}
|
||||
|
||||
/**
|
||||
* parseMatrixKind reads the `matrix` hash page param (ConsumersPage.vue,
|
||||
* mirroring its own `view` param's parsing) into a MatrixKind — unknown
|
||||
* or absent values (a stale/hand-edited link, or the clean default hash
|
||||
* with no `matrix` param at all) fall back to `'models'` rather than
|
||||
* throwing.
|
||||
*/
|
||||
export function parseMatrixKind(raw: string | undefined): MatrixKind {
|
||||
return (MATRIX_KINDS as readonly string[]).includes(raw ?? '') ? (raw as MatrixKind) : 'models'
|
||||
}
|
||||
|
||||
/** AccessMatrixRow is one group's full row: its own provider/MCP-server/agent cells, each keyed by that column's own name — AccessMatrix.vue iterates `providerNames`/`mcpServers`/`agents` (the caller's own column order) and looks up each cell by name rather than relying on object key iteration order. */
|
||||
export interface AccessMatrixRow {
|
||||
group: string
|
||||
@@ -92,3 +121,33 @@ export function buildAccessMatrix(
|
||||
agents: Object.fromEntries(agents.map((target) => [target.name, targetAccessCell(group, target)])),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* MatrixColumnsState is what AccessMatrix.vue's CardContent renders in
|
||||
* place of a table for the CURRENTLY SELECTED kind's own columns —
|
||||
* distinct from the separate consumers.error/consumers.loading/
|
||||
* rows.length===0 branches ahead of it in the template, which are about
|
||||
* GROUPS (rows, stores/consumers.ts), a different store entirely.
|
||||
*/
|
||||
export type MatrixColumnsState = 'loading' | 'error' | 'empty' | 'ready'
|
||||
|
||||
/**
|
||||
* matrixColumnsState decides which of the four states above the current
|
||||
* kind's own column source is in. `sourceLoaded` must be whether the
|
||||
* RAW dashboard field is non-null (`dashboard.overview !== null` for
|
||||
* 'models', `dashboard.targets !== null` for 'mcp'/'agents') — NOT
|
||||
* whether the derived column list (which maps a null source to `[]`) is
|
||||
* empty, since those two are indistinguishable by column count alone.
|
||||
* Previously AccessMatrix.vue read `columns.length === 0` on its own to
|
||||
* decide whether to show "No providers configured." etc — true both
|
||||
* while the source had not loaded yet (or had just failed) AND once it
|
||||
* had genuinely loaded with zero columns, so a page still on its first
|
||||
* fetch, or whose fetch had just failed, showed "no MCP servers
|
||||
* configured" as a confirmed fact about the fleet rather than "still
|
||||
* loading" or the real error. `dashboardError` (dashboard.ts's own
|
||||
* `error` field) is read only in the 'error' state.
|
||||
*/
|
||||
export function matrixColumnsState(sourceLoaded: boolean, columnCount: number, dashboardError: string): MatrixColumnsState {
|
||||
if (!sourceLoaded) return dashboardError ? 'error' : 'loading'
|
||||
return columnCount === 0 ? 'empty' : 'ready'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { loadState } from './load-state'
|
||||
|
||||
describe('loadState', () => {
|
||||
it('is "ready" when there is data, no error, not loading', () => {
|
||||
expect(loadState({ loading: false, hasData: true, error: '' })).toBe('ready')
|
||||
})
|
||||
|
||||
it('is "ready" when there is data even if loading is true (a background refresh in flight)', () => {
|
||||
expect(loadState({ loading: true, hasData: true, error: '' })).toBe('ready')
|
||||
})
|
||||
|
||||
// states-plan.md item 1: a background refresh failure must never blank
|
||||
// already-rendered data into an error state — that surfaces via a
|
||||
// toast instead (stores/toasts.ts), not by switching away from 'ready'.
|
||||
it('is "ready" when there is data even if a background refresh just failed (error set)', () => {
|
||||
expect(loadState({ loading: false, hasData: true, error: 'usage: fetch failed' })).toBe('ready')
|
||||
})
|
||||
|
||||
it('is "error" when there is no data and an error is set', () => {
|
||||
expect(loadState({ loading: false, hasData: false, error: 'overview: fetch failed' })).toBe('error')
|
||||
})
|
||||
|
||||
it('is "error" even while loading, when there is no data and an error is set (a retry already in flight)', () => {
|
||||
expect(loadState({ loading: true, hasData: false, error: 'overview: fetch failed' })).toBe('error')
|
||||
})
|
||||
|
||||
it('is "skeleton" when there is no data, no error, and loading is true', () => {
|
||||
expect(loadState({ loading: true, hasData: false, error: '' })).toBe('skeleton')
|
||||
})
|
||||
|
||||
it('is "empty" when there is no data, no error, and not loading', () => {
|
||||
expect(loadState({ loading: false, hasData: false, error: '' })).toBe('empty')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* LoadState is the four states any data-driven view in this panel
|
||||
* renders in place of its real content — the single vocabulary
|
||||
* EmptyState.vue/ErrorState.vue/the Skeleton* components all key off of,
|
||||
* so every page decides "skeleton vs error vs empty vs ready" the same
|
||||
* way instead of each growing its own ad-hoc v-if chain.
|
||||
*/
|
||||
export type LoadState = 'skeleton' | 'error' | 'empty' | 'ready'
|
||||
|
||||
/** LoadStateInput is loadState's own three signals — every store in this panel already exposes something that maps onto these (a `loading`/`lastUpdated` flag, a data field's null-ness or length, an `error` string). */
|
||||
export interface LoadStateInput {
|
||||
/** Whether a fetch for the CURRENT selection is in flight (or has never yet completed) — a background refresh of data already on screen does not count (see hasData's own precedence below). */
|
||||
loading: boolean
|
||||
/** Whether there is currently displayable data for the CURRENT selection — false right after a filter/selection change clears the previous selection's data, not just on a cold first load. */
|
||||
hasData: boolean
|
||||
/** The store's current error message, '' when there is none. */
|
||||
error: string
|
||||
}
|
||||
|
||||
/**
|
||||
* loadState decides which of the four LoadState values a view should
|
||||
* render, from the three signals above. Precedence, highest first:
|
||||
*
|
||||
* 1. 'ready' whenever `hasData` is true — even alongside a non-empty
|
||||
* `error` (a background poll just failed but the PREVIOUS successful
|
||||
* result is still on screen): states-plan.md item 1's own rule is
|
||||
* that a background refresh must never replace rendered data with a
|
||||
* skeleton, and a background failure must never blank it into an
|
||||
* error state either — that case surfaces via a toast instead
|
||||
* (stores/toasts.ts), not by this helper switching states.
|
||||
* 2. 'error' when there is no data and `error` is set — the first fetch
|
||||
* for this selection failed outright, nothing to show underneath it.
|
||||
* 3. 'skeleton' when there is no data, no error, and `loading` is true —
|
||||
* first load, or a filter/selection change that cleared the previous
|
||||
* selection's own data and kicked off a fresh fetch.
|
||||
* 4. 'empty' otherwise — not loading, no error, genuinely no data.
|
||||
*/
|
||||
export function loadState({ loading, hasData, error }: LoadStateInput): LoadState {
|
||||
if (hasData) return 'ready'
|
||||
if (error) return 'error'
|
||||
if (loading) return 'skeleton'
|
||||
return 'empty'
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { composeTargetId, parseTargetCallerId, targetCallerColumns, targetColumns } from './target-columns'
|
||||
import type { AdminTargetHealthView, AdminTargetView, AdminTotalsResponse } from '@/types/api'
|
||||
import { composeTargetId, parseTargetCallerId, resolveTargetRef, targetCallerColumns, targetColumns } from './target-columns'
|
||||
import type { AdminTargetHealthView, AdminTargetsResponse, AdminTargetView, AdminTotalsResponse } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Target health rendering (feat/target-health): mirrors usage-columns.
|
||||
@@ -276,3 +276,77 @@ describe('targetCallerColumns', () => {
|
||||
expect(isVNode(rendered) && rendered.props?.value).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// --- resolveTargetRef (P2 item: a hand-typed #targets?target=demo-mcp
|
||||
// 400ed — parseTargetRef, stats_read.go, only accepts "mcp/{name}"/
|
||||
// "agent/{name}") ---
|
||||
|
||||
function namedTarget(name: string): AdminTargetView {
|
||||
return {
|
||||
name,
|
||||
url: 'https://example.com/mcp',
|
||||
counters: { requestsPerMinute: 0, requestsPerDay: 0, requestsPerMonth: 0 },
|
||||
health: { state: 'unknown', consecutiveFailures: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function targets(mcpNames: string[], agentNames: string[]): Pick<AdminTargetsResponse, 'mcpServers' | 'agents'> {
|
||||
return { mcpServers: mcpNames.map(namedTarget), agents: agentNames.map(namedTarget) }
|
||||
}
|
||||
|
||||
describe('resolveTargetRef', () => {
|
||||
it('passes an already-prefixed "mcp/{name}" ref through unchanged, without needing targets loaded', () => {
|
||||
expect(resolveTargetRef('mcp/fetch', null)).toEqual({ kind: 'ref', ref: 'mcp/fetch', name: 'fetch' })
|
||||
})
|
||||
|
||||
it('passes an already-prefixed "agent/{name}" ref through unchanged', () => {
|
||||
expect(resolveTargetRef('agent/agentkit', targets([], ['agentkit']))).toEqual({
|
||||
kind: 'ref',
|
||||
ref: 'agent/agentkit',
|
||||
name: 'agentkit',
|
||||
})
|
||||
})
|
||||
|
||||
it('round-trips a name that itself contains a slash, matching stats_read.go\'s Cut-on-first-slash', () => {
|
||||
expect(resolveTargetRef('mcp/team/fetch', null)).toEqual({ kind: 'ref', ref: 'mcp/team/fetch', name: 'team/fetch' })
|
||||
})
|
||||
|
||||
it('is "pending" for a bare name while dashboard.targets has not loaded yet (null)', () => {
|
||||
expect(resolveTargetRef('demo-mcp', null)).toEqual({ kind: 'pending' })
|
||||
})
|
||||
|
||||
it('resolves a bare name found only in mcpServers', () => {
|
||||
expect(resolveTargetRef('demo-mcp', targets(['demo-mcp'], []))).toEqual({
|
||||
kind: 'ref',
|
||||
ref: 'mcp/demo-mcp',
|
||||
name: 'demo-mcp',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a bare name found only in agents', () => {
|
||||
expect(resolveTargetRef('agentkit', targets([], ['agentkit']))).toEqual({
|
||||
kind: 'ref',
|
||||
ref: 'agent/agentkit',
|
||||
name: 'agentkit',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a notice for a bare name found in neither list (unknown)', () => {
|
||||
const result = resolveTargetRef('nope', targets(['demo-mcp'], ['agentkit']))
|
||||
expect(result.kind).toBe('notice')
|
||||
expect(result.kind === 'notice' && result.message).toContain('No MCP server or agent named "nope"')
|
||||
})
|
||||
|
||||
it('shows a notice for a bare name found in BOTH lists (ambiguous) rather than guessing', () => {
|
||||
const result = resolveTargetRef('shared', targets(['shared'], ['shared']))
|
||||
expect(result.kind).toBe('notice')
|
||||
expect(result.kind === 'notice' && result.message).toContain('matches both an MCP server and an agent')
|
||||
})
|
||||
|
||||
it('rejects an unknown prefix as a bare name, not a ref (e.g. "http/foo" is looked up, not passed through)', () => {
|
||||
expect(resolveTargetRef('http/foo', targets([], []))).toEqual({
|
||||
kind: 'notice',
|
||||
message: 'No MCP server or agent named "http/foo" is configured.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { h } from 'vue'
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatElapsedAgo, formatLatencyMs } from '@/lib/format'
|
||||
import type { AdminTargetHealthView, AdminTargetView, AdminTotalsResponse } from '@/types/api'
|
||||
import type { AdminTargetHealthView, AdminTargetsResponse, AdminTargetView, AdminTotalsResponse } from '@/types/api'
|
||||
|
||||
/**
|
||||
* numericColumn builds one right-aligned, sortable request-counter
|
||||
@@ -210,6 +210,68 @@ export function composeTargetId(kind: TargetKind, name: string): string {
|
||||
return `${kind}/${name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* TargetRefResolution is TargetsPage.vue's own read of the `target` hash
|
||||
* param, normalized for GET /admin/api/usage/totals?kind=targetcaller&
|
||||
* target= (admin.go's parseTargetRef: only "mcp/{name}" or "agent/{name}"
|
||||
* is valid — a bare name 400s):
|
||||
* - 'ref': `ref` is a valid "mcp/{name}"/"agent/{name}" composite,
|
||||
* safe to pass straight to TargetCallers.vue; `name` is the bare
|
||||
* name alone, for a nicer fallback label than the full composite.
|
||||
* - 'pending': `raw` was a bare name and dashboard.targets has not
|
||||
* loaded yet (still null) — there is nothing to resolve against
|
||||
* yet, so the caller shows neither a table nor a notice.
|
||||
* - 'notice': `raw` could not be resolved to exactly one target —
|
||||
* either no configured MCP server or agent has that name, or (rarer)
|
||||
* BOTH an MCP server and an agent share it, so guessing either one
|
||||
* would risk showing the wrong caller breakdown. `message` is ready
|
||||
* to render as-is.
|
||||
*/
|
||||
export type TargetRefResolution =
|
||||
| { kind: 'ref'; ref: string; name: string }
|
||||
| { kind: 'pending' }
|
||||
| { kind: 'notice'; message: string }
|
||||
|
||||
/** parsePrefixedTargetRef reads a "mcp/{name}"/"agent/{name}" composite — mirrors stats_read.go's own parseTargetRef (Cut on the FIRST "/", so a name containing further slashes still round-trips) — or null for anything else (a bare name, an unknown prefix, or an empty name). */
|
||||
function parsePrefixedTargetRef(raw: string): { kind: TargetKind; name: string } | null {
|
||||
const cut = raw.indexOf('/')
|
||||
if (cut === -1) return null
|
||||
const prefix = raw.slice(0, cut)
|
||||
const name = raw.slice(cut + 1)
|
||||
if (name === '' || (prefix !== 'mcp' && prefix !== 'agent')) return null
|
||||
return { kind: prefix, name }
|
||||
}
|
||||
|
||||
/**
|
||||
* resolveTargetRef normalizes TargetsPage.vue's `target` hash param
|
||||
* (P2 item: a hand-typed `#targets?target=demo-mcp` 400ed — the backend
|
||||
* only accepts the prefixed composite form, never a bare name) into a
|
||||
* TargetRefResolution: an already-prefixed `raw` ("mcp/x"/"agent/x")
|
||||
* passes through unchanged (server-side existence is not checked here —
|
||||
* an unknown target under a valid prefix just returns zero rows,
|
||||
* TargetCallers.vue's own existing "no callers recorded" empty state,
|
||||
* not an error); a bare name is looked up in `targets.mcpServers` first,
|
||||
* then `targets.agents` — resolved only when it matches EXACTLY ONE of
|
||||
* the two lists, otherwise 'notice' ('unknown' for neither, 'ambiguous'
|
||||
* for both, since silently guessing which kind the reader meant could
|
||||
* show the wrong target's callers).
|
||||
*/
|
||||
export function resolveTargetRef(raw: string, targets: Pick<AdminTargetsResponse, 'mcpServers' | 'agents'> | null): TargetRefResolution {
|
||||
const prefixed = parsePrefixedTargetRef(raw)
|
||||
if (prefixed) return { kind: 'ref', ref: composeTargetId(prefixed.kind, prefixed.name), name: prefixed.name }
|
||||
|
||||
if (targets === null) return { kind: 'pending' }
|
||||
|
||||
const inMcp = targets.mcpServers.some((t) => t.name === raw)
|
||||
const inAgents = targets.agents.some((t) => t.name === raw)
|
||||
if (inMcp && inAgents) {
|
||||
return { kind: 'notice', message: `"${raw}" matches both an MCP server and an agent — use "mcp/${raw}" or "agent/${raw}" to pick one.` }
|
||||
}
|
||||
if (inMcp) return { kind: 'ref', ref: composeTargetId('mcp', raw), name: raw }
|
||||
if (inAgents) return { kind: 'ref', ref: composeTargetId('agent', raw), name: raw }
|
||||
return { kind: 'notice', message: `No MCP server or agent named "${raw}" is configured.` }
|
||||
}
|
||||
|
||||
/**
|
||||
* ParsedTargetCallerId is one targetcaller row's `id`
|
||||
* ("mcp/fetch/alice" — AdminTotalsResponse's own doc comment) split into
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ADMIN_MAX_KEYS_PER_REQUEST,
|
||||
DEFAULT_TOP_USERS_MODE,
|
||||
parseTopUsersMode,
|
||||
rankTopUsers,
|
||||
TOP_USERS_METRICS_BY_MODE,
|
||||
TOP_USERS_MODE_LABEL,
|
||||
TOP_USERS_MODES,
|
||||
topUsersCapCheckResult,
|
||||
topUsersCapExceeded,
|
||||
topUsersTotalsUrl,
|
||||
} from './top-users'
|
||||
import type { AdminTotalsResponse } from '@/types/api'
|
||||
|
||||
function row(id: string, values: Partial<Record<'req' | 'tokin' | 'tokout' | 'cost', number>>): AdminTotalsResponse['rows'][number] {
|
||||
return { id, values: { req: 0, tokin: 0, tokout: 0, cost: 0, ...values } }
|
||||
}
|
||||
|
||||
describe('TOP_USERS_METRICS_BY_MODE', () => {
|
||||
it('requests only the metric(s) each mode ranks by', () => {
|
||||
expect(TOP_USERS_METRICS_BY_MODE.cost).toEqual(['cost'])
|
||||
expect(TOP_USERS_METRICS_BY_MODE.req).toEqual(['req'])
|
||||
expect(TOP_USERS_METRICS_BY_MODE.tokens).toEqual(['tokin', 'tokout'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('topUsersTotalsUrl', () => {
|
||||
it('builds the cost-mode query string with metrics=cost', () => {
|
||||
expect(topUsersTotalsUrl('day', 7, 'cost')).toBe('/admin/api/usage/totals?kind=user&window=day&span=7&offset=0&metrics=cost&limit=1000')
|
||||
})
|
||||
|
||||
it('builds the req-mode query string with metrics=req', () => {
|
||||
expect(topUsersTotalsUrl('hour', 24, 'req')).toBe('/admin/api/usage/totals?kind=user&window=hour&span=24&offset=0&metrics=req&limit=1000')
|
||||
})
|
||||
|
||||
it('builds the tokens-mode query string with metrics=tokin,tokout', () => {
|
||||
expect(topUsersTotalsUrl('month', 12, 'tokens')).toBe(
|
||||
'/admin/api/usage/totals?kind=user&window=month&span=12&offset=0&metrics=tokin%2Ctokout&limit=1000',
|
||||
)
|
||||
})
|
||||
|
||||
it('honours an explicit limit override', () => {
|
||||
expect(topUsersTotalsUrl('day', 7, 'cost', 50)).toBe('/admin/api/usage/totals?kind=user&window=day&span=7&offset=0&metrics=cost&limit=50')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ADMIN_MAX_KEYS_PER_REQUEST', () => {
|
||||
it('mirrors the server constant (stats_read.go: adminMaxKeysPerRequest)', () => {
|
||||
expect(ADMIN_MAX_KEYS_PER_REQUEST).toBe(64000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('topUsersCapExceeded', () => {
|
||||
it('mirrors stats_read.go:573 exactly: len(ids)*len(metrics)*span > cap', () => {
|
||||
// 1000 users * 1 metric * 48h span = 48000, under the 64000 cap.
|
||||
expect(topUsersCapExceeded(1000, 1, 48)).toBe(false)
|
||||
// 1334 users * 1 metric * 48h span = 64032, over the cap.
|
||||
expect(topUsersCapExceeded(1334, 1, 48)).toBe(true)
|
||||
})
|
||||
|
||||
it('is false at exactly the cap (strict >, not >=, matching the server)', () => {
|
||||
expect(topUsersCapExceeded(64000, 1, 1)).toBe(false)
|
||||
expect(topUsersCapExceeded(64001, 1, 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('scales with metrics count — tokens mode (2 metrics) trips sooner than cost/req (1 metric)', () => {
|
||||
expect(topUsersCapExceeded(700, 1, 48)).toBe(false)
|
||||
expect(topUsersCapExceeded(700, 2, 48)).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for zero users, metrics, or span', () => {
|
||||
expect(topUsersCapExceeded(0, 1, 48)).toBe(false)
|
||||
expect(topUsersCapExceeded(500, 0, 48)).toBe(false)
|
||||
expect(topUsersCapExceeded(500, 1, 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// verify-ui-states-2.md #4: HomePage.vue's own configuredUserCount is now
|
||||
// an EXACT count (dashboard.usage.users.length), but is null before that
|
||||
// store has ever loaded — topUsersCapCheckResult is the wrapper that
|
||||
// decides what to do with that null case (skip the predictive check
|
||||
// entirely, rather than treating null as 0 or guessing).
|
||||
describe('topUsersCapCheckResult', () => {
|
||||
it('skips the predictive check (returns false) when configuredUserCount is null', () => {
|
||||
// Would trip the cap if it were treated as any real number here.
|
||||
expect(topUsersCapCheckResult(null, 2, 48)).toBe(false)
|
||||
})
|
||||
|
||||
it('delegates to topUsersCapExceeded once configuredUserCount is a real number', () => {
|
||||
expect(topUsersCapCheckResult(1334, 1, 48)).toBe(true)
|
||||
expect(topUsersCapCheckResult(1000, 1, 48)).toBe(false)
|
||||
})
|
||||
|
||||
it('is false for a genuinely zero configured user count', () => {
|
||||
expect(topUsersCapCheckResult(0, 2, 48)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseTopUsersMode', () => {
|
||||
it('parses each valid mode', () => {
|
||||
expect(parseTopUsersMode('cost')).toBe('cost')
|
||||
expect(parseTopUsersMode('req')).toBe('req')
|
||||
expect(parseTopUsersMode('tokens')).toBe('tokens')
|
||||
})
|
||||
|
||||
it('falls back to cost for an unknown value', () => {
|
||||
expect(parseTopUsersMode('bogus')).toBe('cost')
|
||||
})
|
||||
|
||||
it('falls back to cost when undefined', () => {
|
||||
expect(parseTopUsersMode(undefined)).toBe(DEFAULT_TOP_USERS_MODE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TOP_USERS_MODES / TOP_USERS_MODE_LABEL', () => {
|
||||
it('orders the three modes cost, req, tokens', () => {
|
||||
expect(TOP_USERS_MODES).toEqual(['cost', 'req', 'tokens'])
|
||||
})
|
||||
|
||||
it('labels every mode', () => {
|
||||
expect(TOP_USERS_MODE_LABEL).toEqual({ cost: 'Cost', req: 'Requests', tokens: 'Tokens' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rankTopUsers', () => {
|
||||
it('ranks by cost descending', () => {
|
||||
const rows = [row('alice', { cost: 5 }), row('bob', { cost: 20 }), row('carl', { cost: 10 })]
|
||||
expect(rankTopUsers(rows, 'cost')).toEqual([
|
||||
{ id: 'bob', value: 20 },
|
||||
{ id: 'carl', value: 10 },
|
||||
{ id: 'alice', value: 5 },
|
||||
])
|
||||
})
|
||||
|
||||
it('ranks by requests descending', () => {
|
||||
const rows = [row('alice', { req: 5, cost: 999 }), row('bob', { req: 20, cost: 1 })]
|
||||
expect(rankTopUsers(rows, 'req')).toEqual([
|
||||
{ id: 'bob', value: 20 },
|
||||
{ id: 'alice', value: 5 },
|
||||
])
|
||||
})
|
||||
|
||||
it('ranks by tokens as tokin+tokout summed, descending', () => {
|
||||
const rows = [row('alice', { tokin: 100, tokout: 50 }), row('bob', { tokin: 10, tokout: 10 })]
|
||||
expect(rankTopUsers(rows, 'tokens')).toEqual([
|
||||
{ id: 'alice', value: 150 },
|
||||
{ id: 'bob', value: 20 },
|
||||
])
|
||||
})
|
||||
|
||||
it('breaks ties by id ascending', () => {
|
||||
const rows = [row('zed', { cost: 10 }), row('amy', { cost: 10 })]
|
||||
expect(rankTopUsers(rows, 'cost')).toEqual([
|
||||
{ id: 'amy', value: 10 },
|
||||
{ id: 'zed', value: 10 },
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes zero rows for the CURRENT mode only', () => {
|
||||
// bob has requests but $0 cost (free-model-only traffic): out of the
|
||||
// cost ranking, but present in the requests ranking.
|
||||
const rows = [row('alice', { cost: 5, req: 1 }), row('bob', { cost: 0, req: 9 })]
|
||||
expect(rankTopUsers(rows, 'cost')).toEqual([{ id: 'alice', value: 5 }])
|
||||
expect(rankTopUsers(rows, 'req')).toEqual([
|
||||
{ id: 'bob', value: 9 },
|
||||
{ id: 'alice', value: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('truncates to the limit (default 5)', () => {
|
||||
const rows = Array.from({ length: 8 }, (_, i) => row(`user${i}`, { cost: 8 - i }))
|
||||
expect(rankTopUsers(rows, 'cost')).toHaveLength(5)
|
||||
expect(rankTopUsers(rows, 'cost')[0]).toEqual({ id: 'user0', value: 8 })
|
||||
})
|
||||
|
||||
it('honours a custom limit', () => {
|
||||
const rows = [row('a', { cost: 3 }), row('b', { cost: 2 }), row('c', { cost: 1 })]
|
||||
expect(rankTopUsers(rows, 'cost', 2)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('returns an empty array when every row is zero for the mode', () => {
|
||||
const rows = [row('alice', { cost: 0 }), row('bob', { cost: 0 })]
|
||||
expect(rankTopUsers(rows, 'cost')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an empty array for no rows', () => {
|
||||
expect(rankTopUsers([], 'cost')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { AdminTotalsResponse, HistoryWindow } from '@/types/api'
|
||||
|
||||
/**
|
||||
* TOP_USERS_MODES is HomePage.vue's "top users" card Tabs switch (Cost |
|
||||
* Requests | Tokens) — the values match the `top` hash param verbatim
|
||||
* ('req'/'tokens'; 'cost' is the default and is never written to the
|
||||
* hash at all, mirroring lib/access-matrix.ts's MATRIX_KINDS/`matrix`
|
||||
* param convention), so no separate internal-vs-URL name mapping exists.
|
||||
*/
|
||||
export const TOP_USERS_MODES = ['cost', 'req', 'tokens'] as const
|
||||
|
||||
export type TopUsersMode = (typeof TOP_USERS_MODES)[number]
|
||||
|
||||
export const DEFAULT_TOP_USERS_MODE: TopUsersMode = 'cost'
|
||||
|
||||
/** TOP_USERS_MODE_LABEL is each mode's Tabs label and the source for the card's "Top 5 users by …" title. */
|
||||
export const TOP_USERS_MODE_LABEL: Record<TopUsersMode, string> = {
|
||||
cost: 'Cost',
|
||||
req: 'Requests',
|
||||
tokens: 'Tokens',
|
||||
}
|
||||
|
||||
/**
|
||||
* ADMIN_MAX_KEYS_PER_REQUEST mirrors the server's own adminMaxKeysPerRequest
|
||||
* (stats_read.go:28, `const adminMaxKeysPerRequest = 64000`) — GET
|
||||
* /admin/api/usage/totals?kind=user rejects with 400 the moment
|
||||
* `len(ids) * len(metrics) * span` exceeds this, where `ids` is EVERY
|
||||
* configured user (stats_read.go:566-576: built from `g.auth.snapshot()`'s
|
||||
* userSummaries, not filtered to users with recent traffic). `dashboard.
|
||||
* usage.users` (admin.go's own userSummaries, already polled every 5s) IS
|
||||
* an exact, free source for this count — HomePage.vue's own
|
||||
* configuredUserCount reads it directly (verify-ui-states-2.md #4; an
|
||||
* earlier revision of this comment wrongly claimed no such source
|
||||
* existed and used a per-group memberCount sum instead, which over-counts
|
||||
* a user who belongs to more than one group). topUsersCapExceeded below
|
||||
* re-implements that same inequality for topUsersCapCheckResult's
|
||||
* best-effort predictive check.
|
||||
*/
|
||||
export const ADMIN_MAX_KEYS_PER_REQUEST = 64000
|
||||
|
||||
/**
|
||||
* TOP_USERS_METRICS_BY_MODE (P1 fix, verify-ui-states.md #1) — each mode
|
||||
* requests ONLY the metric(s) it ranks by, not all four unconditionally: a
|
||||
* fetch this card used to always shape as `metrics=req,tokin,tokout,cost`
|
||||
* (4 metrics) now costs 1 metric (cost/req) or 2 (tokens, summed
|
||||
* client-side by rankTopUsers below) against the server's own
|
||||
* len(ids)*len(metrics)*span cap — up to 4x more configured users before a
|
||||
* fleet trips adminMaxKeysPerRequest on the coarsest (hour, span 48) global
|
||||
* range. This is the ONLY lever this card has over that cap: `ids` is
|
||||
* fixed by server-side config, and the `limit` query param (below) is
|
||||
* applied AFTER the cap check, over already-fetched rows (buildTotalsRows,
|
||||
* stats_read.go:453-480) — it does not, and cannot, reduce the read cost
|
||||
* the cap guards.
|
||||
*/
|
||||
export const TOP_USERS_METRICS_BY_MODE: Record<TopUsersMode, readonly string[]> = {
|
||||
cost: ['cost'],
|
||||
req: ['req'],
|
||||
tokens: ['tokin', 'tokout'],
|
||||
}
|
||||
|
||||
/**
|
||||
* parseTopUsersMode reads the `top` hash param into a TopUsersMode —
|
||||
* unknown or absent values fall back to 'cost' rather than throwing,
|
||||
* mirroring lib/access-matrix.ts's parseMatrixKind.
|
||||
*/
|
||||
export function parseTopUsersMode(raw: string | undefined): TopUsersMode {
|
||||
return (TOP_USERS_MODES as readonly string[]).includes(raw ?? '') ? (raw as TopUsersMode) : DEFAULT_TOP_USERS_MODE
|
||||
}
|
||||
|
||||
/**
|
||||
* TOP_USERS_FETCH_LIMIT (1000, the server's own adminTotalsMaxLimit,
|
||||
* stats_read.go) is headroom for CLIENT-SIDE re-ranking, not a cap-safety
|
||||
* lever (see TOP_USERS_METRICS_BY_MODE's own doc comment — `limit` is
|
||||
* applied after the cap check, over already-fetched rows). It matters only
|
||||
* for 'tokens' mode: the server sorts by metrics[0] ('tokin') descending
|
||||
* and truncates to `limit` BEFORE this card re-ranks by tokin+tokout
|
||||
* summed (rankTopUsers below) — a `limit` too small could truncate away a
|
||||
* user with a large tokout but a middling tokin before the client ever
|
||||
* gets a chance to re-rank them in. 1000 keeps that reordering effect
|
||||
* practically unreachable for any real fleet.
|
||||
*/
|
||||
export const TOP_USERS_FETCH_LIMIT = 1000
|
||||
|
||||
/** TOP_USERS_RANK_LIMIT is how many rows rankTopUsers keeps after ranking — the card's own "top 5". */
|
||||
export const TOP_USERS_RANK_LIMIT = 5
|
||||
|
||||
/**
|
||||
* topUsersTotalsUrl builds HomePage.vue's "top users" query string for one
|
||||
* mode: GET /admin/api/usage/totals?kind=user&window=<window>&span=<span>&
|
||||
* offset=0&metrics=<mode's own metrics>&limit=<limit>. Built directly (not
|
||||
* via lib/range.ts's totalsUrl) because totalsUrl's own `offset` convention
|
||||
* deliberately OMITS the param when 0 (server default, seriesUrl/totalsUrl's
|
||||
* shared doc comment, lib/range.spec.ts's own "omits offset when 0" test) —
|
||||
* this card's own fixed shape always writes `offset=0` explicitly, so it
|
||||
* cannot reuse that builder without changing totalsUrl's behavior for every
|
||||
* other caller.
|
||||
*/
|
||||
export function topUsersTotalsUrl(window: HistoryWindow, span: number, mode: TopUsersMode, limit: number = TOP_USERS_FETCH_LIMIT): string {
|
||||
const q = new URLSearchParams()
|
||||
q.set('kind', 'user')
|
||||
q.set('window', window)
|
||||
q.set('span', String(span))
|
||||
q.set('offset', '0')
|
||||
q.set('metrics', TOP_USERS_METRICS_BY_MODE[mode].join(','))
|
||||
q.set('limit', String(limit))
|
||||
return `/admin/api/usage/totals?${q.toString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* topUsersCapExceeded mirrors the server's own kind=user cap check
|
||||
* (stats_read.go:573: `len(ids)*len(metrics)*span > adminMaxKeysPerRequest`)
|
||||
* so HomePage.vue can decide, BEFORE firing a request, whether it would
|
||||
* come back 400 — `userCount` is the caller's own count of every
|
||||
* CONFIGURED user (HomePage.vue's configuredUserCount, an EXACT count as
|
||||
* of verify-ui-states-2.md #4, not an estimate). Deliberately a plain,
|
||||
* easily-tested predicate over three numbers rather than something that
|
||||
* reaches into a store itself.
|
||||
*/
|
||||
export function topUsersCapExceeded(userCount: number, metricsCount: number, span: number): boolean {
|
||||
return userCount * metricsCount * span > ADMIN_MAX_KEYS_PER_REQUEST
|
||||
}
|
||||
|
||||
/**
|
||||
* topUsersCapCheckResult (verify-ui-states-2.md #4) wraps topUsersCapExceeded
|
||||
* for HomePage.vue's actual call site: `configuredUserCount` is null before
|
||||
* dashboard.usage has ever loaded (a cold mount, or a load failure) — the
|
||||
* predictive check is skipped entirely in that case (returns false, so
|
||||
* fetchTopUsers fires the request) rather than guessing at a count, and the
|
||||
* server's own real 400 (fetchTopUsers's own catch, matched on "range too
|
||||
* large") still covers whatever this happens to miss once usage loads late.
|
||||
*/
|
||||
export function topUsersCapCheckResult(configuredUserCount: number | null, metricsCount: number, span: number): boolean {
|
||||
if (configuredUserCount === null) return false
|
||||
return topUsersCapExceeded(configuredUserCount, metricsCount, span)
|
||||
}
|
||||
|
||||
/** One ranked "top users" row — the id plus the single numeric value the current mode ranked it by (already summed for 'tokens'). HomePage.vue formats `value` itself (formatCost for 'cost', formatCompactCount for 'req'/'tokens') — this stays a plain number so the sort/tiebreak logic below never depends on a formatted string. */
|
||||
export interface RankedTopUser {
|
||||
id: string
|
||||
value: number
|
||||
}
|
||||
|
||||
/** topUserValue reads the single metric (or metric sum) `mode` ranks by out of one totals row — 'tokens' sums tokin+tokout, matching TOP_USERS_METRICS_BY_MODE's own request shape; a metric absent from `values` (should not happen given TOP_USERS_METRICS_BY_MODE, but Record<string, number> guarantees no key) reads as 0. */
|
||||
function topUserValue(row: { values: Record<string, number> }, mode: TopUsersMode): number {
|
||||
switch (mode) {
|
||||
case 'cost':
|
||||
return row.values.cost ?? 0
|
||||
case 'req':
|
||||
return row.values.req ?? 0
|
||||
case 'tokens':
|
||||
return (row.values.tokin ?? 0) + (row.values.tokout ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* rankTopUsers ranks ONE fetched totals response client-side for `mode` —
|
||||
* Cost/Requests/Tokens each sort by their own value descending, ties broken
|
||||
* by id ascending; a row whose CURRENT MODE's value is 0 is excluded (a
|
||||
* user with requests but $0 cost, e.g. free-model-only traffic, drops out
|
||||
* of the Cost ranking but stays in the Requests one) — distinct from the
|
||||
* server's own all-metrics-zero drop (stats_read.go's buildTotalsRows),
|
||||
* which this re-implements for the one metric set actually requested
|
||||
* rather than relying on it.
|
||||
*/
|
||||
export function rankTopUsers(
|
||||
rows: AdminTotalsResponse['rows'],
|
||||
mode: TopUsersMode,
|
||||
limit: number = TOP_USERS_RANK_LIMIT,
|
||||
): RankedTopUser[] {
|
||||
return rows
|
||||
.map((row): RankedTopUser => ({ id: row.id, value: topUserValue(row, mode) }))
|
||||
.filter((row) => row.value > 0)
|
||||
.sort((a, b) => (b.value !== a.value ? b.value - a.value : a.id.localeCompare(b.id)))
|
||||
.slice(0, limit)
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import { computed, onMounted } from 'vue'
|
||||
|
||||
import ChangeHelper from '@/components/ChangeHelper.vue'
|
||||
import ConfigTree from '@/components/ConfigTree.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useConsumersStore } from '@/stores/consumers'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
@@ -32,13 +34,21 @@ const userNames = computed(() => (consumers.data?.users ?? []).map((u) => u.name
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Card v-if="config.error">
|
||||
<Card v-if="config.error && !config.data">
|
||||
<CardHeader>
|
||||
<CardTitle>Config</CardTitle>
|
||||
<CardDescription class="text-destructive">{{ config.error }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ErrorState :message="config.error" :on-retry="config.fetchConfig" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<!-- Mirrors ConfigTree's own final SnippetBlock shape (a label line above a bordered block) so it causes no layout shift once the real config arrives. -->
|
||||
<Card v-else-if="config.loading && !config.data">
|
||||
<CardContent class="flex flex-col gap-1.5">
|
||||
<Skeleton class="h-3 w-56" />
|
||||
<Skeleton class="h-64 w-full rounded-md" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p v-else-if="config.loading && !config.data" class="text-sm text-muted-foreground">loading…</p>
|
||||
<ConfigTree v-else-if="config.data" :config="config.data.config" :warnings="config.data.warnings" />
|
||||
|
||||
<Card>
|
||||
|
||||
@@ -5,21 +5,25 @@ import AccessMatrix from '@/components/AccessMatrix.vue'
|
||||
import ConsumerDirectory from '@/components/ConsumerDirectory.vue'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import UserDetail from '@/components/UserDetail.vue'
|
||||
import { parseMatrixKind } from '@/lib/access-matrix'
|
||||
import type { MatrixKind } from '@/lib/access-matrix'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
|
||||
/**
|
||||
* ConsumersPage (redesign-plan.md section 3.4) is the Consumers page's own
|
||||
* router: `user=<name>` (set by an EntityLink kind="user" click anywhere in
|
||||
* the panel) opens UserDetail; otherwise `view=directory|matrix` (default
|
||||
* directory) switches between ConsumerDirectory and AccessMatrix. Both
|
||||
* params live in nav.params (stores/nav.ts's own opaque per-page record),
|
||||
* never a private local ref — a bookmarked/shared #consumers?user=... link
|
||||
* must restore the same view.
|
||||
* directory) switches between ConsumerDirectory and AccessMatrix, and
|
||||
* (P4 three-view split) `matrix=models|mcp|agents` (default models) picks
|
||||
* AccessMatrix's own column-group tab. All three params live in nav.params
|
||||
* (stores/nav.ts's own opaque per-page record), never a private local ref —
|
||||
* a bookmarked/shared #consumers?user=... link must restore the same view.
|
||||
*/
|
||||
const nav = useNavStore()
|
||||
|
||||
const userParam = computed(() => nav.params.user)
|
||||
const view = computed<'directory' | 'matrix'>(() => (nav.params.view === 'matrix' ? 'matrix' : 'directory'))
|
||||
const matrixKind = computed<MatrixKind>(() => parseMatrixKind(nav.params.matrix))
|
||||
|
||||
function setView(next: unknown): void {
|
||||
if (next !== 'directory' && next !== 'matrix') return
|
||||
@@ -27,6 +31,19 @@ function setView(next: unknown): void {
|
||||
nav.goTo('consumers', { ...rest, view: next })
|
||||
}
|
||||
|
||||
/**
|
||||
* setMatrixKind writes the AccessMatrix Tabs selection into nav.params:
|
||||
* omit `matrix` when 'models'; `view` is always written.
|
||||
*/
|
||||
function setMatrixKind(next: MatrixKind): void {
|
||||
const { user: _user, matrix: _matrix, ...rest } = nav.params
|
||||
if (next === 'models') {
|
||||
nav.goTo('consumers', { ...rest, view: 'matrix' })
|
||||
return
|
||||
}
|
||||
nav.goTo('consumers', { ...rest, view: 'matrix', matrix: next })
|
||||
}
|
||||
|
||||
function closeUserDetail(): void {
|
||||
const { user: _user, ...rest } = nav.params
|
||||
nav.goTo('consumers', rest)
|
||||
@@ -43,6 +60,6 @@ function closeUserDetail(): void {
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<ConsumerDirectory v-if="view === 'directory'" />
|
||||
<AccessMatrix v-else />
|
||||
<AccessMatrix v-else :kind="matrixKind" @update:kind="setMatrixKind" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+348
-69
@@ -1,12 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import KpiTile from '@/components/KpiTile.vue'
|
||||
import SkeletonKpiTile from '@/components/SkeletonKpiTile.vue'
|
||||
import SkeletonList from '@/components/SkeletonList.vue'
|
||||
import TopList from '@/components/TopList.vue'
|
||||
import type { TopListItem } from '@/components/TopList.vue'
|
||||
import TryLongerRangeButton from '@/components/TryLongerRangeButton.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { adminFetch } from '@/lib/api'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { EVENT_KIND_LABEL, eventKindVariant } from '@/lib/events-filter'
|
||||
import { formatCompactCount, formatCost, formatTimestamp } from '@/lib/format'
|
||||
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
|
||||
@@ -18,13 +23,25 @@ import {
|
||||
openBreakerCount,
|
||||
sumGroupBudgetMicros,
|
||||
} from '@/lib/kpi'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
import { createVisibilityPoller } from '@/lib/polling'
|
||||
import type { VisibilityPoller } from '@/lib/polling'
|
||||
import { totalsUrl } from '@/lib/range'
|
||||
import {
|
||||
DEFAULT_TOP_USERS_MODE,
|
||||
parseTopUsersMode,
|
||||
rankTopUsers,
|
||||
TOP_USERS_METRICS_BY_MODE,
|
||||
TOP_USERS_MODE_LABEL,
|
||||
TOP_USERS_MODES,
|
||||
topUsersCapCheckResult,
|
||||
topUsersTotalsUrl,
|
||||
} from '@/lib/top-users'
|
||||
import type { TopUsersMode } from '@/lib/top-users'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useEventsStore } from '@/stores/events'
|
||||
import { useFiltersStore } from '@/stores/filters'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
import type { AdminEventKind, AdminTotalsResponse, AdminUsageModelsResponse } from '@/types/api'
|
||||
|
||||
/**
|
||||
@@ -46,6 +63,7 @@ const auth = useAuthStore()
|
||||
const dashboard = useDashboardStore()
|
||||
const events = useEventsStore()
|
||||
const filters = useFiltersStore()
|
||||
const nav = useNavStore()
|
||||
|
||||
// --- Spend vs budget ---------------------------------------------------
|
||||
|
||||
@@ -101,50 +119,266 @@ const breakerCount = computed<number>(() => openBreakerCount(dashboard.overview?
|
||||
const warningCount = computed<number>(() => dashboard.overview?.warnings.length ?? 0)
|
||||
const unpricedCount = computed<number>(() => dashboard.overview?.pricing.unpriced ?? 0)
|
||||
|
||||
// --- Top 5 users by cost / top 5 models by cost --------------------------
|
||||
/**
|
||||
* kpiTilesState (states-plan.md item 1) gates the KPI grid's skeleton/error
|
||||
* branches via the shared lib/load-state.ts helper — every KpiTile computed
|
||||
* above already defaults to a sensible zero/fallback reading (`?? 0`, "not
|
||||
* enough data yet") when dashboard.overview/usage is null, which used to
|
||||
* render as a confusing flash of "$0.0000"/"0" tiles (or "no traffic") on
|
||||
* first load, or a PERMANENT one on a first-load failure — indistinguishable
|
||||
* from a fleet that has genuinely spent/seen nothing, rather than a loading
|
||||
* or error state. `loading: true` mirrors TargetsView.vue's/AccessMatrix.vue's
|
||||
* own identical idiom for a source with no separate in-flight flag of its
|
||||
* own: it is only ever consulted once `hasData` is already false, at which
|
||||
* point this page has nothing else to show but a skeleton anyway.
|
||||
*
|
||||
* `hasData` (verify-ui-states-2.md #5 fix) is `dashboard.overview !== null
|
||||
* && dashboard.usage !== null`, NOT `dashboard.lastUpdated !== null` —
|
||||
* dashboard.ts's own doRefresh still advances lastUpdated on a PARTIAL
|
||||
* success (`failures.length < 3`), which can leave overview and/or usage
|
||||
* still null (e.g. only /targets succeeded) while every tile above already
|
||||
* falls back to a `?? 0`/"no traffic" reading off that null source. The
|
||||
* old lastUpdated-based check read that combination as 'ready' and
|
||||
* rendered those fallbacks as real fleet facts ("$0.0000", "0" breakers)
|
||||
* instead of the error this page actually has to show. `overview`/`usage`
|
||||
* are never cleared on a failed refresh (dashboard.ts only ever assigns a
|
||||
* FULFILLED result), so once both have landed once, a later background
|
||||
* failure still reads 'ready' and the KPI figures stay stable across polls
|
||||
* — loadState's own "ready wins over a stale error" precedence.
|
||||
*/
|
||||
const kpiTilesState = computed(() =>
|
||||
loadState({ loading: true, hasData: dashboard.overview !== null && dashboard.usage !== null, error: dashboard.error }),
|
||||
)
|
||||
|
||||
const topUsers = ref<TopListItem[]>([])
|
||||
// --- Top 5 users by cost/requests/tokens / top 5 models by cost ----------
|
||||
|
||||
/**
|
||||
* topUsersMode is the "top users" card's Cost/Requests/Tokens Tabs
|
||||
* selection, kept in nav.params.top (lib/top-users.ts's own `top` hash
|
||||
* param convention: 'req'/'tokens' written, 'cost' omitted as the
|
||||
* default) so a reload or shared link restores the same ranking —
|
||||
* mirrors AccessMatrix.vue's `kind` prop / ConsumersPage.vue's
|
||||
* `matrix` param wiring.
|
||||
*/
|
||||
const topUsersMode = computed<TopUsersMode>(() => parseTopUsersMode(nav.params.top))
|
||||
|
||||
function setTopUsersMode(next: unknown): void {
|
||||
if (typeof next !== 'string' || !(TOP_USERS_MODES as readonly string[]).includes(next)) return
|
||||
const mode = next as TopUsersMode
|
||||
if (mode === DEFAULT_TOP_USERS_MODE) {
|
||||
const { top: _top, ...rest } = nav.params
|
||||
nav.goTo('home', rest)
|
||||
return
|
||||
}
|
||||
nav.goTo('home', { ...nav.params, top: mode })
|
||||
}
|
||||
|
||||
/**
|
||||
* configuredUserCount (verify-ui-states-2.md #4 fix) is the EXACT
|
||||
* population the server's own kind=user cap check counts (stats_read.go:
|
||||
* 566-576, `ids` built from auth.snapshot()'s userSummaries) — read
|
||||
* directly off dashboard.usage.users, which has exactly one entry per
|
||||
* configured user (admin.go's own userSummaries, already polled every 5s).
|
||||
* An earlier revision summed each already-polled group's own memberCount
|
||||
* instead (dashboard.overview) — that over-counts any user who belongs to
|
||||
* more than one group, which could trip topUsersCapExceeded (lib/
|
||||
* top-users.ts) for a range the server would have served fine. Null before
|
||||
* dashboard.usage has ever loaded (a cold mount, or a load failure) —
|
||||
* topUsersCapCheckResult skips the predictive check entirely in that case
|
||||
* rather than guessing; fetchTopUsers below still catches the server's
|
||||
* own real 400 as a fallback for whatever this happens to miss.
|
||||
*/
|
||||
const configuredUserCount = computed<number | null>(() => dashboard.usage?.users.length ?? null)
|
||||
|
||||
/** topUsersRaw is the CURRENT mode's fetched /usage/totals?kind=user rows — topUsersCache below keeps every mode's own last-fetched rows so a tab switch back to an already-seen mode can show them immediately (states-plan.md item 1/4). */
|
||||
const topUsersRaw = ref<AdminTotalsResponse['rows']>([])
|
||||
const topModels = ref<TopListItem[]>([])
|
||||
const topListsError = ref('')
|
||||
/** topListsReqId guards against an out-of-order response overwriting a newer one — the 60s visibility-poller tick and a range change fire fetchTopLists() independently, so two calls can be in flight together (P2 item 7). */
|
||||
let topListsReqId = 0
|
||||
const topUsersLoading = ref(false)
|
||||
const topUsersError = ref('')
|
||||
const topModelsLoading = ref(false)
|
||||
const topModelsError = ref('')
|
||||
/**
|
||||
* topModelsLoaded (verify-ui-states-2.md #2 fix) is "has a fetch completed
|
||||
* for the CURRENT range's top-models card" — TopList.vue's own `loaded`
|
||||
* prop — set only on a successful fetchTopModels, reset on a genuine range
|
||||
* change (the watcher below). Distinct from `topModels.length > 0`: a
|
||||
* genuinely empty top-models card must keep reading as settled/empty
|
||||
* across the 60s poll's own `topModelsLoading` flips, not flash a skeleton
|
||||
* on every tick.
|
||||
*/
|
||||
const topModelsLoaded = ref(false)
|
||||
/**
|
||||
* topUsersRangeTooLarge (verify-ui-states.md #1) is set either predictively
|
||||
* (configuredUserCount trips topUsersCapCheckResult, so fetchTopUsers
|
||||
* never even fires the doomed request) or reactively (the server's own 400
|
||||
* for this exact reason — AdminApiError.status === 400 with its "range too
|
||||
* large" message, stats_read.go:574) — either way this renders TopList's
|
||||
* own `notice` slot instead of a raw error, naming the actual fix (a
|
||||
* shorter range) rather than surfacing the server's terse 400 text.
|
||||
*/
|
||||
const topUsersRangeTooLarge = ref(false)
|
||||
/**
|
||||
* topUsersCache keeps the last successfully fetched raw rows PER MODE, for
|
||||
* the CURRENT range only (cleared in the range watcher below) — a tab
|
||||
* switch to an already-fetched mode shows its rows immediately (no
|
||||
* skeleton) while a background refetch (still fired — "refetch on tab
|
||||
* switch", verify-ui-states.md #1) brings it current, the same
|
||||
* cached-reopen principle UserDetail.vue's own detail cache follows.
|
||||
* `reactive()` (not a plain Map, verify-ui-states-2.md #2 fix): topUsersLoaded
|
||||
* below reads `.has()` inside a `computed`, which needs Vue to track the
|
||||
* Map's own mutations (`.set()`/`.clear()`) as reactive dependencies — a
|
||||
* plain Map's mutations are invisible to Vue's reactivity system, so the
|
||||
* computed would never re-run when a fetch actually completes.
|
||||
*/
|
||||
const topUsersCache = reactive(new Map<TopUsersMode, AdminTotalsResponse['rows']>())
|
||||
/**
|
||||
* topUsersLoaded (verify-ui-states-2.md #2 fix) is "has a fetch completed
|
||||
* for the CURRENT mode" — TopList.vue's own `loaded` prop for the top-users
|
||||
* card — reusing topUsersCache's own per-mode presence (set only on a
|
||||
* successful fetch, in fetchTopUsers below) rather than a duplicate flag: a
|
||||
* mode's cache entry existing already means at least one response has
|
||||
* landed for it in the current range, even when every one of its rows
|
||||
* ranks to 0 for this mode (rankTopUsers filters those out, e.g. Cost mode
|
||||
* over free-model-only traffic) — distinct from `topUsers.length > 0`,
|
||||
* which a genuinely empty selection can never satisfy and which used to
|
||||
* flash a skeleton back on over that settled empty state on every 60s poll
|
||||
* tick (`topUsersLoading` flips true on every tick, regardless of whether
|
||||
* this is the mode's first fetch).
|
||||
*/
|
||||
const topUsersLoaded = computed<boolean>(() => topUsersCache.has(topUsersMode.value))
|
||||
/** topUsersReqId/topModelsReqId are two INDEPENDENT latest-request-wins counters (not one shared counter): a mode switch only refetches users, a range change refetches both, and the 60s poller ticks both — a shared counter would make an in-flight models fetch spuriously discard a still-relevant users fetch, or vice versa, merely for starting first. */
|
||||
let topUsersReqId = 0
|
||||
let topModelsReqId = 0
|
||||
|
||||
async function fetchTopLists(): Promise<void> {
|
||||
/** formatTopUserValue applies the existing formatter each mode's unit calls for — formatCost (already 2-decimal-cents-safe) for money, formatCompactCount for raw counts/token sums. */
|
||||
function formatTopUserValue(mode: TopUsersMode, value: number): string {
|
||||
return mode === 'cost' ? formatCost(value) : formatCompactCount(value)
|
||||
}
|
||||
|
||||
const topUsers = computed<TopListItem[]>(() =>
|
||||
rankTopUsers(topUsersRaw.value, topUsersMode.value).map((row) => ({
|
||||
id: row.id,
|
||||
label: row.id,
|
||||
value: formatTopUserValue(topUsersMode.value, row.value),
|
||||
kind: 'user' as const,
|
||||
})),
|
||||
)
|
||||
|
||||
const topUsersTitle = computed(() => `Top 5 users by ${TOP_USERS_MODE_LABEL[topUsersMode.value].toLowerCase()}`)
|
||||
|
||||
/**
|
||||
* fetchTopUsers (verify-ui-states.md #1) requests ONLY the current mode's
|
||||
* own metric(s) — lib/top-users.ts's TOP_USERS_METRICS_BY_MODE — instead of
|
||||
* always asking for all four, and is called again on every mode switch
|
||||
* (the watch below), not just on mount/poll/range-change, since a
|
||||
* different mode now means a genuinely different request. Independent of
|
||||
* fetchTopModels (own loading/error/reqId) so one failing never blanks the
|
||||
* other's card.
|
||||
*/
|
||||
async function fetchTopUsers(): Promise<void> {
|
||||
if (!auth.isAuthenticated) return
|
||||
const requestId = ++topListsReqId
|
||||
const mode = topUsersMode.value
|
||||
const metricsCount = TOP_USERS_METRICS_BY_MODE[mode].length
|
||||
if (topUsersCapCheckResult(configuredUserCount.value, metricsCount, filters.span)) {
|
||||
// verify-ui-states-2.md #1: invalidate any OLDER in-flight fetch (a
|
||||
// different mode's request, or the previous range's) the same way a
|
||||
// genuine fetch below does — without bumping topUsersReqId here, a
|
||||
// request that was already in flight before this predictive block
|
||||
// still passes the `requestId !== topUsersReqId` check once it lands,
|
||||
// and writes its own (now wrong mode/range) rows out from under this
|
||||
// notice.
|
||||
++topUsersReqId
|
||||
topUsersLoading.value = false
|
||||
topUsersRangeTooLarge.value = true
|
||||
topUsersError.value = ''
|
||||
return
|
||||
}
|
||||
const requestId = ++topUsersReqId
|
||||
topUsersLoading.value = true
|
||||
try {
|
||||
const [usersRes, modelsRes] = await Promise.all([
|
||||
adminFetch<AdminTotalsResponse>(
|
||||
totalsUrl({ kind: 'user', window: filters.window, span: filters.span, metrics: ['cost'], limit: TOP_LIST_LIMIT }),
|
||||
),
|
||||
adminFetch<AdminUsageModelsResponse>(
|
||||
`/admin/api/usage/models?metric=cost&window=${filters.window}&span=${filters.span}&limit=${TOP_LIST_LIMIT}`,
|
||||
),
|
||||
])
|
||||
if (requestId !== topListsReqId) return
|
||||
topUsers.value = usersRes.rows.map((row) => ({
|
||||
id: row.id,
|
||||
label: row.id,
|
||||
value: formatCost(row.values.cost ?? 0),
|
||||
kind: 'user' as const,
|
||||
}))
|
||||
topModels.value = modelsRes.models.map((m) => ({
|
||||
const res = await adminFetch<AdminTotalsResponse>(topUsersTotalsUrl(filters.window, filters.span, mode))
|
||||
if (requestId !== topUsersReqId) return
|
||||
topUsersCache.set(mode, res.rows)
|
||||
topUsersRaw.value = res.rows
|
||||
topUsersError.value = ''
|
||||
topUsersRangeTooLarge.value = false
|
||||
} catch (err) {
|
||||
if (requestId !== topUsersReqId) return
|
||||
if (err instanceof AdminApiError && err.status === 400 && /range too large/i.test(err.message)) {
|
||||
topUsersRangeTooLarge.value = true
|
||||
topUsersError.value = ''
|
||||
} else {
|
||||
topUsersError.value = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
} finally {
|
||||
if (requestId === topUsersReqId) topUsersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTopModels(): Promise<void> {
|
||||
if (!auth.isAuthenticated) return
|
||||
const requestId = ++topModelsReqId
|
||||
topModelsLoading.value = true
|
||||
try {
|
||||
const res = await adminFetch<AdminUsageModelsResponse>(
|
||||
`/admin/api/usage/models?metric=cost&window=${filters.window}&span=${filters.span}&limit=${TOP_LIST_LIMIT}`,
|
||||
)
|
||||
if (requestId !== topModelsReqId) return
|
||||
topModels.value = res.models.map((m) => ({
|
||||
id: m.id,
|
||||
label: m.id,
|
||||
value: formatCost(m.value),
|
||||
kind: 'model' as const,
|
||||
}))
|
||||
topListsError.value = ''
|
||||
topModelsError.value = ''
|
||||
topModelsLoaded.value = true
|
||||
} catch (err) {
|
||||
if (requestId !== topListsReqId) return
|
||||
topListsError.value = err instanceof Error ? err.message : String(err)
|
||||
if (requestId !== topModelsReqId) return
|
||||
topModelsError.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
if (requestId === topModelsReqId) topModelsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** fetchTopLists fires both independently (Promise.allSettled, not Promise.all) — each of fetchTopUsers/fetchTopModels already catches its own errors internally, so neither can reject and blank the other's card (verify-ui-states.md #1). The one shared entry point mount/poll/range-change all call. */
|
||||
async function fetchTopLists(): Promise<void> {
|
||||
await Promise.allSettled([fetchTopUsers(), fetchTopModels()])
|
||||
}
|
||||
|
||||
let topListsPoller: VisibilityPoller | undefined
|
||||
|
||||
watch(topUsersMode, (mode) => {
|
||||
// A tab switch is a genuine SELECTION change, but — unlike a range
|
||||
// change — an already-fetched mode's rows stay cached (topUsersCache):
|
||||
// show them immediately (no skeleton flash for data already in hand),
|
||||
// then still fire a background refetch so they come current.
|
||||
const cached = topUsersCache.get(mode)
|
||||
topUsersRaw.value = cached ?? []
|
||||
topUsersError.value = ''
|
||||
topUsersRangeTooLarge.value = false
|
||||
void fetchTopUsers()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => filters.range,
|
||||
() => void fetchTopLists(),
|
||||
() => {
|
||||
// An explicit range change is a genuine SELECTION change for BOTH
|
||||
// cards, unlike the 60s poller's own background tick — states-plan.md
|
||||
// item 1's rule ("skeleton only for the current selection's own
|
||||
// missing data") means the previous range's rows (and every mode's
|
||||
// cache, which is only ever valid for the range it was fetched under)
|
||||
// are cleared here so the skeleton shows for the new range's own
|
||||
// fetch, rather than leaving the old range's numbers on screen under
|
||||
// the (about to change) heading until the new response lands.
|
||||
topUsersCache.clear()
|
||||
topUsersRaw.value = []
|
||||
topModels.value = []
|
||||
topModelsLoaded.value = false
|
||||
topUsersError.value = ''
|
||||
topModelsError.value = ''
|
||||
topUsersRangeTooLarge.value = false
|
||||
void fetchTopLists()
|
||||
},
|
||||
)
|
||||
|
||||
// --- Latest 8 events -------------------------------------------------------
|
||||
@@ -156,6 +390,10 @@ function kindLabel(kind: string): string {
|
||||
|
||||
const latestEvents = computed(() => events.events.slice(0, 8))
|
||||
|
||||
/** eventsCardState (states-plan.md gap item: "Home Latest events card still says 'No events recorded yet.' while loading or on error") mirrors EventsView.vue's own eventsLoadState — hasData is "has this store ever completed a fetch", not "are there events", so a clean fleet with lastUpdated set still reads 'ready' and falls through to noEventsRecorded below, not a permanent skeleton. */
|
||||
const eventsCardState = computed(() => loadState({ loading: events.refreshing, hasData: events.lastUpdated !== null, error: events.error }))
|
||||
const noEventsRecorded = computed(() => latestEvents.value.length === 0)
|
||||
|
||||
onMounted(() => {
|
||||
// Events store is polled only while a page that reads it is mounted
|
||||
// (its own doc comment) — Home is one more such consumer, alongside
|
||||
@@ -173,47 +411,86 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<section aria-label="Key metrics" class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<KpiTile label="Spend (MTD)" :value="formatCost(spendMtdMicros)" :delta="spendDelta" :tier="spendTier" :to="{ page: 'spend' }" />
|
||||
<KpiTile
|
||||
label="Projected month-end"
|
||||
:value="projectedValue"
|
||||
:delta="projectedDelta"
|
||||
:tier="projectedTier"
|
||||
:to="{ page: 'spend' }"
|
||||
/>
|
||||
<KpiTile label="Requests/min" :value="formatCompactCount(requestsPerMinute)" />
|
||||
<KpiTile
|
||||
label="Error rate (last minute)"
|
||||
:value="errorValue"
|
||||
:delta="errorDelta"
|
||||
:tier="errorTier"
|
||||
:to="{ page: 'reliability' }"
|
||||
/>
|
||||
<KpiTile
|
||||
label="Open breakers (per replica)"
|
||||
:value="String(breakerCount)"
|
||||
:tier="breakerCount > 0 ? 'warn' : 'ok'"
|
||||
:to="{ page: 'models' }"
|
||||
/>
|
||||
<KpiTile
|
||||
label="Config warnings"
|
||||
:value="String(warningCount)"
|
||||
:tier="warningCount > 0 ? 'warn' : 'ok'"
|
||||
:to="{ page: 'config' }"
|
||||
/>
|
||||
<KpiTile
|
||||
label="Unpriced models"
|
||||
:value="String(unpricedCount)"
|
||||
:tier="unpricedCount > 0 ? 'warn' : 'ok'"
|
||||
:to="{ page: 'spend' }"
|
||||
/>
|
||||
<template v-if="kpiTilesState === 'skeleton'">
|
||||
<SkeletonKpiTile v-for="i in 7" :key="i" />
|
||||
</template>
|
||||
<div v-else-if="kpiTilesState === 'error'" class="col-span-full">
|
||||
<ErrorState :message="dashboard.error" :on-retry="dashboard.refresh" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<KpiTile label="Spend (MTD)" :value="formatCost(spendMtdMicros)" :delta="spendDelta" :tier="spendTier" :to="{ page: 'spend' }" />
|
||||
<KpiTile
|
||||
label="Projected month-end"
|
||||
:value="projectedValue"
|
||||
:delta="projectedDelta"
|
||||
:tier="projectedTier"
|
||||
:to="{ page: 'spend' }"
|
||||
/>
|
||||
<KpiTile label="Requests/min" :value="formatCompactCount(requestsPerMinute)" />
|
||||
<KpiTile
|
||||
label="Error rate (last minute)"
|
||||
:value="errorValue"
|
||||
:delta="errorDelta"
|
||||
:tier="errorTier"
|
||||
:to="{ page: 'reliability' }"
|
||||
/>
|
||||
<KpiTile
|
||||
label="Open breakers (per replica)"
|
||||
:value="String(breakerCount)"
|
||||
:tier="breakerCount > 0 ? 'warn' : 'ok'"
|
||||
:to="{ page: 'models' }"
|
||||
/>
|
||||
<KpiTile
|
||||
label="Config warnings"
|
||||
:value="String(warningCount)"
|
||||
:tier="warningCount > 0 ? 'warn' : 'ok'"
|
||||
:to="{ page: 'config' }"
|
||||
/>
|
||||
<KpiTile
|
||||
label="Unpriced models"
|
||||
:value="String(unpricedCount)"
|
||||
:tier="unpricedCount > 0 ? 'warn' : 'ok'"
|
||||
:to="{ page: 'spend' }"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<p v-if="topListsError" class="text-sm text-destructive">Top lists failed to load: {{ topListsError }}</p>
|
||||
|
||||
<section aria-label="Top spenders and models" class="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<TopList title="Top 5 users by cost" :items="topUsers" empty-text="No spend in this range yet." />
|
||||
<TopList title="Top 5 models by cost" :items="topModels" empty-text="No spend in this range yet." />
|
||||
<TopList
|
||||
:title="topUsersTitle"
|
||||
:items="topUsers"
|
||||
empty-text="No traffic in this range."
|
||||
:loading="topUsersLoading"
|
||||
:loaded="topUsersLoaded"
|
||||
:error="topUsersError"
|
||||
:on-retry="fetchTopUsers"
|
||||
:notice="topUsersRangeTooLarge ? 'Too many configured users for this range.' : undefined"
|
||||
notice-description="Pick a shorter range above — a fleet this size needs a narrower window to rank users."
|
||||
>
|
||||
<template #controls>
|
||||
<Tabs :model-value="topUsersMode" @update:model-value="setTopUsersMode">
|
||||
<TabsList>
|
||||
<TabsTrigger v-for="m in TOP_USERS_MODES" :key="m" :value="m">{{ TOP_USERS_MODE_LABEL[m] }}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</template>
|
||||
<template #empty-action>
|
||||
<TryLongerRangeButton />
|
||||
</template>
|
||||
</TopList>
|
||||
<TopList
|
||||
title="Top 5 models by cost"
|
||||
:items="topModels"
|
||||
empty-text="No traffic in this range."
|
||||
:loading="topModelsLoading"
|
||||
:loaded="topModelsLoaded"
|
||||
:error="topModelsError"
|
||||
:on-retry="fetchTopModels"
|
||||
>
|
||||
<template #empty-action>
|
||||
<TryLongerRangeButton />
|
||||
</template>
|
||||
</TopList>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
@@ -221,7 +498,9 @@ onUnmounted(() => {
|
||||
<CardTitle>Latest events</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p v-if="latestEvents.length === 0" class="text-sm text-muted-foreground">No events recorded yet.</p>
|
||||
<SkeletonList v-if="eventsCardState === 'skeleton'" :rows="4" />
|
||||
<ErrorState v-else-if="eventsCardState === 'error'" :message="events.error" :on-retry="events.refresh" />
|
||||
<p v-else-if="noEventsRecorded" class="text-sm text-muted-foreground">No events recorded yet.</p>
|
||||
<ol v-else class="flex flex-col gap-2">
|
||||
<li
|
||||
v-for="(event, i) in latestEvents"
|
||||
|
||||
@@ -54,12 +54,18 @@ watch(
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<ProviderHealthPanel />
|
||||
<p v-if="models.error" class="text-sm text-destructive">{{ models.error }}</p>
|
||||
<!-- verify-ui-states-2.md #8: shown only for a BACKGROUND failure
|
||||
(rows already on screen) — a first-load failure already renders via
|
||||
ModelCatalogTable's own ErrorState below, and showing both duplicated
|
||||
the same message twice. -->
|
||||
<p v-if="models.error && models.rows.length > 0" class="text-sm break-words text-destructive">{{ models.error }}</p>
|
||||
<ModelCatalogTable
|
||||
:rows="models.rows"
|
||||
:query="query"
|
||||
:latency-enabled="models.latencyEnabled"
|
||||
:loading="models.loading"
|
||||
:error="models.error"
|
||||
:on-retry="models.fetch"
|
||||
@update:query="query = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import EventsView from '@/components/EventsView.vue'
|
||||
import SkeletonChart from '@/components/SkeletonChart.vue'
|
||||
import SkeletonList from '@/components/SkeletonList.vue'
|
||||
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
|
||||
import type { TimeSeriesDataset } from '@/components/TimeSeriesChart.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { formatBucketLabel } from '@/lib/format'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
import type { LoadState } from '@/lib/load-state'
|
||||
import { formatErrorRatePercent } from '@/lib/provider-rate'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useEventsStore } from '@/stores/events'
|
||||
@@ -106,6 +113,37 @@ function errorRatePercent(value: number): string {
|
||||
const hasProviders = computed(() => (dashboard.overview?.providers.length ?? 0) > 0)
|
||||
const noFailoverConfigured = computed(() => dashboard.overview !== null && dashboard.overview.features.failover === false)
|
||||
|
||||
/**
|
||||
* dashboardLoadState (live-preview fix, same class as verify-ui-states.md
|
||||
* #3): `hasProviders` alone reads false BOTH when the fleet genuinely has
|
||||
* no configured providers AND before the polled dashboard.overview has
|
||||
* ever landed (or after it failed to) — "No providers configured." used
|
||||
* to render as a false fleet fact on a deep link straight to this page,
|
||||
* before the 5s dashboard poll's first response arrives. Every OTHER page
|
||||
* already reads dashboard.overview after it has settled at least once
|
||||
* (they mount later in a typical session); this page can be the very
|
||||
* first thing mounted on a cold reload.
|
||||
*/
|
||||
const dashboardLoadState = computed(() => loadState({ loading: !dashboard.lastUpdated, hasData: dashboard.overview !== null, error: dashboard.error }))
|
||||
|
||||
/**
|
||||
* providersState (live-preview fix: "per-provider charts briefly render an
|
||||
* empty grid before providers are known") gates the FOUR per-provider chart
|
||||
* cards below (error rate, selected provider performance, timeouts,
|
||||
* failovers) — the same three-way split lib/access-matrix.ts's
|
||||
* matrixColumnsState makes for AccessMatrix.vue's own columns: 'skeleton'
|
||||
* while dashboard.overview has not loaded yet (dashboardLoadState itself
|
||||
* still unsettled — this page is often the very first thing mounted on a
|
||||
* cold reload), 'error' forwarded from that same first-load failure,
|
||||
* 'empty' once overview has genuinely loaded with zero configured
|
||||
* providers (a real fleet fact, not a loading artifact), 'ready' once it
|
||||
* has loaded with at least one provider to chart.
|
||||
*/
|
||||
const providersState = computed<LoadState>(() => {
|
||||
if (dashboardLoadState.value !== 'ready') return dashboardLoadState.value
|
||||
return hasProviders.value ? 'ready' : 'empty'
|
||||
})
|
||||
|
||||
// --- selected-provider performance (N3 fix, redesign-plan.md section
|
||||
// 3.3: "performance series=1 for the selected provider") ---
|
||||
const latencyStatsEnabled = computed(() => dashboard.overview?.features.latencyStats ?? false)
|
||||
@@ -143,6 +181,31 @@ watch(
|
||||
*/
|
||||
const monthRangeClamped = computed(() => filters.window === 'month')
|
||||
|
||||
// --- skeleton/error states (verify-ui-states.md #7: "Spend and
|
||||
// Reliability pages got no skeletons or ErrorState") -----------------------
|
||||
|
||||
/**
|
||||
* reliabilityState (lib/load-state.ts) gates every fleet-wide chart on this
|
||||
* page (error rate, timeouts, failovers, rejections, unpriced) — all five
|
||||
* are populated by the ONE reliability.fetch() call, so one shared state is
|
||||
* correct rather than each chart re-deriving its own from the same three
|
||||
* underlying signals. Checked FIRST: dashboardLoadState — reliability.fetch
|
||||
* (stores/reliability.ts) now no-ops while dashboard.overview has not
|
||||
* loaded yet, rather than resolving as "loaded, empty" (the live-preview
|
||||
* fix this composition closes), so reliability.loading/buckets/error alone
|
||||
* can no longer be trusted to read 'skeleton' during that window; once
|
||||
* dashboardLoadState settles 'ready', this falls through to reliability's
|
||||
* own loading/buckets/error exactly as before.
|
||||
*/
|
||||
const reliabilityState = computed<LoadState>(() => {
|
||||
if (dashboardLoadState.value !== 'ready') return dashboardLoadState.value
|
||||
return loadState({ loading: reliability.loading, hasData: reliability.buckets.length > 0, error: reliability.error })
|
||||
})
|
||||
/** providerPerfState mirrors reliabilityState for the "Selected provider performance" chart pair — reliability.fetchProviderPerf runs independently of fetch() (its own doc comment), so it needs its own loading/error signal, only reachable once providerPerfAvailable is already true. */
|
||||
const providerPerfState = computed(() =>
|
||||
loadState({ loading: reliability.providerPerfLoading, hasData: reliability.providerPerfPoints.length > 0, error: reliability.providerPerfError }),
|
||||
)
|
||||
|
||||
// Refetch whenever the global range changes (Q12 DECISIONS: "new endpoints
|
||||
// on demand or 60s" — this is the "on demand" case, driven by the reader's
|
||||
// own range choice rather than a timer) or once the dashboard's 5s poll
|
||||
@@ -212,7 +275,9 @@ onMounted(() => void reliability.fetch())
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p v-if="reliability.error" class="text-sm text-destructive">{{ reliability.error }}</p>
|
||||
<SkeletonList v-if="dashboardLoadState === 'skeleton'" :rows="2" />
|
||||
<ErrorState v-else-if="dashboardLoadState === 'error'" :message="dashboard.error" :on-retry="dashboard.refresh" />
|
||||
<p v-else-if="reliability.error" class="text-sm break-words text-destructive">{{ reliability.error }}</p>
|
||||
<p v-else-if="!hasProviders" class="text-sm text-muted-foreground">No providers configured.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -224,71 +289,133 @@ onMounted(() => void reliability.fetch())
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card v-if="hasProviders">
|
||||
<CardHeader>
|
||||
<CardTitle>Error rate by provider</CardTitle>
|
||||
<CardDescription>Failed attempts / total attempts per bucket. A gap means no attempts in that bucket.</CardDescription>
|
||||
</CardHeader>
|
||||
<!--
|
||||
providersState (live-preview fix: "per-provider charts briefly render
|
||||
an empty grid before providers are known") gates this whole group of
|
||||
FOUR per-provider cards as one unit — 'skeleton' while
|
||||
dashboard.overview has not loaded, 'empty' once it has loaded with
|
||||
zero configured providers (a real fleet fact, shown once via a single
|
||||
EmptyState rather than four repeated ones), 'ready' to render the
|
||||
actual charts below exactly as before.
|
||||
-->
|
||||
<template v-if="providersState === 'skeleton'">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Error rate by provider</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent><SkeletonChart /></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Selected provider performance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent><SkeletonChart /></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Timeouts by provider</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent><SkeletonChart /></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Failovers by provider</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent><SkeletonChart /></CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
<Card v-else-if="providersState === 'empty'">
|
||||
<CardContent>
|
||||
<TimeSeriesChart :labels="bucketLabels" :datasets="errorRateDatasets" :value-formatter="errorRatePercent" ariaLabel="Error rate by provider over time" />
|
||||
<EmptyState
|
||||
title="No providers configured."
|
||||
description="Add a providers entry to the middleware config to see per-provider reliability charts here."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<template v-else-if="providersState === 'ready'">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Error rate by provider</CardTitle>
|
||||
<CardDescription>Failed attempts / total attempts per bucket. A gap means no attempts in that bucket.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SkeletonChart v-if="reliabilityState === 'skeleton'" />
|
||||
<ErrorState v-else-if="reliabilityState === 'error'" :message="reliability.error" :on-retry="reliability.fetch" />
|
||||
<TimeSeriesChart v-else :labels="bucketLabels" :datasets="errorRateDatasets" :value-formatter="errorRatePercent" ariaLabel="Error rate by provider over time" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-if="hasProviders">
|
||||
<CardHeader>
|
||||
<CardTitle>Selected provider performance</CardTitle>
|
||||
<CardDescription>Latency (p50/p95) and failures per bucket for one provider.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-4">
|
||||
<p v-if="selectedProvider === ''" class="text-sm text-muted-foreground">
|
||||
Select a provider above to see its latency and failure breakdown per bucket.
|
||||
</p>
|
||||
<p v-else-if="!latencyStatsEnabled" class="text-sm text-muted-foreground">
|
||||
Enable <code class="font-mono text-xs">admin.stats.latency</code> in the plugin config to see per-bucket latency for
|
||||
{{ selectedProvider }}.
|
||||
</p>
|
||||
<template v-else-if="providerPerfAvailable">
|
||||
<p v-if="reliability.providerPerfError" class="text-sm text-destructive">{{ reliability.providerPerfError }}</p>
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-muted-foreground">Latency (ms)</p>
|
||||
<TimeSeriesChart
|
||||
:labels="providerPerfBucketLabels"
|
||||
:datasets="providerLatencyDatasets"
|
||||
:ariaLabel="`p50/p95 latency for ${selectedProvider} over time`"
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Selected provider performance</CardTitle>
|
||||
<CardDescription>Latency (p50/p95) and failures per bucket for one provider.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-4">
|
||||
<p v-if="selectedProvider === ''" class="text-sm text-muted-foreground">
|
||||
Select a provider above to see its latency and failure breakdown per bucket.
|
||||
</p>
|
||||
<Alert v-else-if="!latencyStatsEnabled" variant="warn">
|
||||
<AlertTitle>Latency statistics are off</AlertTitle>
|
||||
<AlertDescription class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
>Enable <code class="font-mono text-xs">admin.stats.latency</code> in the middleware config to see per-bucket latency
|
||||
for {{ selectedProvider }}.</span
|
||||
>
|
||||
<Button type="button" variant="outline" size="sm" @click="nav.goTo('config')">Open Config</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<template v-else-if="providerPerfAvailable">
|
||||
<SkeletonChart v-if="providerPerfState === 'skeleton'" />
|
||||
<ErrorState
|
||||
v-else-if="providerPerfState === 'error'"
|
||||
:message="reliability.providerPerfError"
|
||||
:on-retry="() => reliability.fetchProviderPerf(selectedProvider)"
|
||||
/>
|
||||
<div v-else class="grid gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-muted-foreground">Latency (ms)</p>
|
||||
<TimeSeriesChart
|
||||
:labels="providerPerfBucketLabels"
|
||||
:datasets="providerLatencyDatasets"
|
||||
:ariaLabel="`p50/p95 latency for ${selectedProvider} over time`"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-muted-foreground">Failures</p>
|
||||
<TimeSeriesChart
|
||||
:labels="providerPerfBucketLabels"
|
||||
:datasets="providerFailureDatasets"
|
||||
:ariaLabel="`Failures for ${selectedProvider} over time`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-muted-foreground">Failures</p>
|
||||
<TimeSeriesChart
|
||||
:labels="providerPerfBucketLabels"
|
||||
:datasets="providerFailureDatasets"
|
||||
:ariaLabel="`Failures for ${selectedProvider} over time`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-if="hasProviders">
|
||||
<CardHeader>
|
||||
<CardTitle>Timeouts by provider</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TimeSeriesChart :labels="bucketLabels" :datasets="timeoutDatasets" ariaLabel="Timeouts by provider over time" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Timeouts by provider</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SkeletonChart v-if="reliabilityState === 'skeleton'" />
|
||||
<ErrorState v-else-if="reliabilityState === 'error'" :message="reliability.error" :on-retry="reliability.fetch" />
|
||||
<TimeSeriesChart v-else :labels="bucketLabels" :datasets="timeoutDatasets" ariaLabel="Timeouts by provider over time" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-if="hasProviders">
|
||||
<CardHeader>
|
||||
<CardTitle>Failovers by provider</CardTitle>
|
||||
<CardDescription v-if="noFailoverConfigured">This deployment has no failover chains configured.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TimeSeriesChart :labels="bucketLabels" :datasets="failoverDatasets" ariaLabel="Failovers by provider over time" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Failovers by provider</CardTitle>
|
||||
<CardDescription v-if="noFailoverConfigured">This deployment has no failover chains configured.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SkeletonChart v-if="reliabilityState === 'skeleton'" />
|
||||
<ErrorState v-else-if="reliabilityState === 'error'" :message="reliability.error" :on-retry="reliability.fetch" />
|
||||
<TimeSeriesChart v-else :labels="bucketLabels" :datasets="failoverDatasets" ariaLabel="Failovers by provider over time" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
@@ -297,7 +424,9 @@ onMounted(() => void reliability.fetch())
|
||||
<CardDescription>Fleet-wide, every scope combined.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TimeSeriesChart :labels="bucketLabels" :datasets="rejectionDatasets" ariaLabel="Rate-limit rejections over time, fleet-wide" />
|
||||
<SkeletonChart v-if="reliabilityState === 'skeleton'" />
|
||||
<ErrorState v-else-if="reliabilityState === 'error'" :message="reliability.error" :on-retry="reliability.fetch" />
|
||||
<TimeSeriesChart v-else :labels="bucketLabels" :datasets="rejectionDatasets" ariaLabel="Rate-limit rejections over time, fleet-wide" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -307,7 +436,9 @@ onMounted(() => void reliability.fetch())
|
||||
<CardDescription>Fleet-wide requests refused for having no resolvable price.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TimeSeriesChart :labels="bucketLabels" :datasets="unpricedDatasets" ariaLabel="Unpriced refusals (402) over time, fleet-wide" />
|
||||
<SkeletonChart v-if="reliabilityState === 'skeleton'" />
|
||||
<ErrorState v-else-if="reliabilityState === 'error'" :message="reliability.error" :on-retry="reliability.fetch" />
|
||||
<TimeSeriesChart v-else :labels="bucketLabels" :datasets="unpricedDatasets" ariaLabel="Unpriced refusals (402) over time, fleet-wide" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,9 @@ import AttributionDrilldown from '@/components/AttributionDrilldown.vue'
|
||||
import BurnDownChart from '@/components/BurnDownChart.vue'
|
||||
import CostAvoidedCard from '@/components/CostAvoidedCard.vue'
|
||||
import CostForecastCard from '@/components/CostForecastCard.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import PricingHealthTable from '@/components/PricingHealthTable.vue'
|
||||
import SkeletonChart from '@/components/SkeletonChart.vue'
|
||||
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
|
||||
import type { TimeSeriesComparisonDataset, TimeSeriesDataset } from '@/components/TimeSeriesChart.vue'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
@@ -13,6 +15,7 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useNow } from '@/composables/useNow'
|
||||
import { cacheSavingsStatus } from '@/lib/cache-savings'
|
||||
import { formatBucketLabel, formatCompactCount, formatCost } from '@/lib/format'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
import { useCatalogStore } from '@/stores/catalog'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useFiltersStore } from '@/stores/filters'
|
||||
@@ -222,6 +225,28 @@ const cacheStatsEnabled = computed<boolean>(() => dashboard.overview?.features.c
|
||||
const cacheSavingsStatusResult = computed(() => cacheSavingsStatus(filters.window))
|
||||
const cacheSavingsAvailable = computed<boolean>(() => cacheSavingsStatusResult.value.available)
|
||||
const cacheSavingsNote = computed<string | null>(() => cacheSavingsStatusResult.value.note)
|
||||
|
||||
// --- skeleton/error states (verify-ui-states.md #7: "Spend and Reliability
|
||||
// pages got no skeletons or ErrorState") -----------------------------------
|
||||
|
||||
/** breakdownState gates the main breakdown chart's own skeleton/error — hasData is bucket presence (a real response landed), not "every value is non-zero", so a genuinely zero-traffic range still reads 'ready' and the chart draws its own honest zero line. BurnDownChart.vue computes its own identical state internally now (it owns its own Card/CardHeader, verify-ui-states.md fix) — SpendPage.vue just forwards spend.burndownLoading/spend.burndownError as props. */
|
||||
const breakdownState = computed(() => loadState({ loading: spend.loading, hasData: spend.buckets.length > 0, error: spend.breakdownError }))
|
||||
|
||||
/**
|
||||
* retryPricingHealthTable (verify-ui-states-2.md #9 fix) is
|
||||
* PricingHealthTable's own Retry action — the table joins
|
||||
* spend.pricingHealthRanking with catalog.modelsById, so a failure whose
|
||||
* ROOT CAUSE is the catalog fetch (catalog.error, shown alongside
|
||||
* spend.pricingHealthError below) never gets fixed by re-running
|
||||
* spend.fetchPricingHealthRanking alone. Always re-runs the pricing fetch;
|
||||
* additionally re-runs catalog.fetch() when the catalog is the one
|
||||
* currently failing.
|
||||
*/
|
||||
function retryPricingHealthTable(): Promise<void> {
|
||||
const tasks: Promise<void>[] = [spend.fetchPricingHealthRanking()]
|
||||
if (catalog.error) tasks.push(catalog.fetch())
|
||||
return Promise.all(tasks).then(() => undefined)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -259,12 +284,27 @@ const cacheSavingsNote = computed<string | null>(() => cacheSavingsStatusResult.
|
||||
Showing <span class="font-mono text-xs">{{ filters.scope }}</span> directly — clear the scope filter to break spend down by
|
||||
model, provider, or group.
|
||||
</p>
|
||||
<p v-if="spend.error" class="text-sm text-destructive">{{ spend.error }}</p>
|
||||
<!-- verify-ui-states-2.md #3: spend.breakdownError still renders via
|
||||
the breakdown chart's own ErrorState below when there is no data at all
|
||||
(breakdownState === 'error') — never a second, duplicate line for that
|
||||
case (the item-8-style duplicate-error bug this fix set also corrected
|
||||
for ModelsPage.vue). verify-ui-states-3.md NEW-1 adds the ONE case
|
||||
ErrorState can't cover: a background refetch (poll tick, or a
|
||||
metric/by/range/scope change) fails while a PREVIOUS successful result
|
||||
is still on screen (breakdownState === 'ready', loadState's own 'ready'
|
||||
precedence) — the failure was otherwise invisible. See the paragraph
|
||||
below the chart. -->
|
||||
<!-- P3 item 23: catalog.error was never rendered — PricingHealthTable/
|
||||
CostAvoidedCard both depend on catalog.modelsById and silently showed
|
||||
empty/"no priced model" with no indication the catalog fetch itself
|
||||
had failed. -->
|
||||
<p v-if="catalog.error" class="text-sm text-destructive">{{ catalog.error }}</p>
|
||||
<p v-if="catalog.error" class="text-sm break-words text-destructive">{{ catalog.error }}</p>
|
||||
<!-- verify-ui-states-3.md pre-existing fix: fetchModelRanking's own
|
||||
failure has its own field (rankingError), never cross-cleared by a
|
||||
later, unrelated fetchBreakdown success — shown unconditionally next to
|
||||
the chart since nothing else surfaces it (by=model then silently renders
|
||||
100% "Other" off a still-empty ranking). -->
|
||||
<p v-if="spend.rankingError" class="text-sm break-words text-destructive">{{ spend.rankingError }}</p>
|
||||
<!-- P3 item 19 / Q5: a requested-but-unavailable comparison (a
|
||||
single-provider scope) used to leave the "vs previous period" toggle
|
||||
pressed with no dashed line and no explanation. -->
|
||||
@@ -272,7 +312,10 @@ const cacheSavingsNote = computed<string | null>(() => cacheSavingsStatusResult.
|
||||
{{ spend.comparisonUnavailableReason }}
|
||||
</p>
|
||||
|
||||
<SkeletonChart v-if="breakdownState === 'skeleton'" />
|
||||
<ErrorState v-else-if="breakdownState === 'error'" :message="spend.breakdownError" :on-retry="spend.refresh" />
|
||||
<TimeSeriesChart
|
||||
v-else
|
||||
:labels="breakdownLabels"
|
||||
:datasets="breakdownDatasets"
|
||||
:comparison-datasets="comparisonDatasets"
|
||||
@@ -280,6 +323,13 @@ const cacheSavingsNote = computed<string | null>(() => cacheSavingsStatusResult.
|
||||
:value-formatter="valueFormatter"
|
||||
:ariaLabel="`${SPEND_METRIC_LABEL[metric]} breakdown ${SPEND_BY_LABEL[by].toLowerCase()} over time`"
|
||||
/>
|
||||
<!-- verify-ui-states-3.md NEW-1: a background refetch failure (poll
|
||||
tick, or a metric/by/range/scope change) while the PREVIOUS selection's
|
||||
data is still on screen — breakdownState stays 'ready' (loadState's own
|
||||
precedence), so the ErrorState above never mounts. Without this line the
|
||||
failure was invisible: the chart kept the old series under the new
|
||||
labels/formatter with no indication anything had gone wrong. -->
|
||||
<p v-if="spend.breakdownError && breakdownState === 'ready'" class="text-sm break-words text-destructive">{{ spend.breakdownError }}</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<BurnDownChart
|
||||
@@ -288,6 +338,9 @@ const cacheSavingsNote = computed<string | null>(() => cacheSavingsStatusResult.
|
||||
:points="spend.burndownPoints"
|
||||
:budget-micros="spend.burndownBudgetMicros"
|
||||
:now="now"
|
||||
:loading="spend.burndownLoading"
|
||||
:error="spend.burndownError"
|
||||
:on-retry="spend.refresh"
|
||||
/>
|
||||
<CostForecastCard v-if="forecastAvailable" :mtd-micros="scopeMtdMicros" :limits="scopeLimits" :now="now" />
|
||||
</div>
|
||||
@@ -302,7 +355,14 @@ const cacheSavingsNote = computed<string | null>(() => cacheSavingsStatusResult.
|
||||
@update:ref-model="onRefUpdate"
|
||||
/>
|
||||
|
||||
<PricingHealthTable :models="spend.pricingHealthRanking" :catalog="catalog.modelsById" />
|
||||
<PricingHealthTable
|
||||
:models="spend.pricingHealthRanking"
|
||||
:catalog="catalog.modelsById"
|
||||
:loading="spend.pricingHealthLoading"
|
||||
:loaded="spend.pricingHealthLoaded"
|
||||
:error="spend.pricingHealthError || catalog.error"
|
||||
:on-retry="retryPricingHealthTable"
|
||||
/>
|
||||
|
||||
<AttributionDrilldown
|
||||
:rows="spend.drilldownRows"
|
||||
@@ -312,6 +372,10 @@ const cacheSavingsNote = computed<string | null>(() => cacheSavingsStatusResult.
|
||||
:user-model-stats-enabled="userModelStatsEnabled"
|
||||
:window="filters.window"
|
||||
:span="filters.span"
|
||||
:loading="spend.drilldownLoading"
|
||||
:loaded="spend.drilldownLoaded"
|
||||
:error="spend.drilldownError"
|
||||
:on-retry="spend.fetchDrilldown"
|
||||
@update:drill="onDrillUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,9 @@ import { computed, reactive } from 'vue'
|
||||
|
||||
import TargetCallers from '@/components/TargetCallers.vue'
|
||||
import TargetsView from '@/components/TargetsView.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { resolveTargetRef } from '@/lib/target-columns'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
|
||||
/**
|
||||
@@ -18,23 +21,46 @@ import { useNavStore } from '@/stores/nav'
|
||||
* hand-edited URL — while the page stayed mounted) and kept in sync
|
||||
* afterwards via nav.goTo so a reader who clicks a different target gets a
|
||||
* bookmarkable/shareable URL, mirroring EntityLink.vue's own
|
||||
* one-param-at-a-time convention.
|
||||
* one-param-at-a-time convention. The raw param is normalized through
|
||||
* lib/target-columns.ts's resolveTargetRef before it ever reaches
|
||||
* TargetCallers — the backend only accepts "mcp/{name}"/"agent/{name}"
|
||||
* (stats_read.go's parseTargetRef), so a hand-typed bare name like
|
||||
* `#targets?target=demo-mcp` used to 400 instead of resolving.
|
||||
*/
|
||||
const nav = useNavStore()
|
||||
const dashboard = useDashboardStore()
|
||||
|
||||
const selectedTargetId = computed<string>(() => nav.params.target ?? '')
|
||||
|
||||
/**
|
||||
* targetResolution is selectedTargetId run through resolveTargetRef
|
||||
* against the live dashboard.targets listing — null when there is no
|
||||
* `target` param at all (resolveTargetRef has no "empty" case of its
|
||||
* own; an empty string would otherwise fall through its bare-name
|
||||
* lookup and misreport 'notice: No MCP server or agent named "" is
|
||||
* configured.', a spurious message on the plain, no-deep-link Targets
|
||||
* page — caught live via the states-plan.md visual verification pass).
|
||||
* Otherwise: 'ref' renders TargetCallers, 'notice' renders the Alert
|
||||
* below instead of calling the API, 'pending' (targets not loaded yet,
|
||||
* only possible for a bare-name param) renders neither until the next
|
||||
* dashboard poll resolves it.
|
||||
*/
|
||||
const targetResolution = computed(() => (selectedTargetId.value === '' ? null : resolveTargetRef(selectedTargetId.value, dashboard.targets)))
|
||||
|
||||
/**
|
||||
* knownLabels remembers the real display name TargetsView.vue's own click
|
||||
* handed selectTarget, keyed by composed id — selectedTargetLabel below
|
||||
* falls back to the raw id (TargetCallers.vue's own card title) whenever
|
||||
* the CURRENT selectedTargetId has no recorded label, e.g. a fresh deep
|
||||
* link that never went through a click (the label is cosmetic only —
|
||||
* TargetCallers.vue's own card title, not a lookup key — so this never
|
||||
* blocks on the targets listing having loaded).
|
||||
* falls back to the resolved bare name (targetResolution's own `name`)
|
||||
* whenever the CURRENT selectedTargetId has no recorded label, e.g. a
|
||||
* fresh deep link that never went through a click (the label is cosmetic
|
||||
* only — TargetCallers.vue's own card title, not a lookup key — so this
|
||||
* never blocks on the targets listing having loaded).
|
||||
*/
|
||||
const knownLabels = reactive<Record<string, string>>({})
|
||||
const selectedTargetLabel = computed<string>(() => knownLabels[selectedTargetId.value] ?? selectedTargetId.value)
|
||||
const selectedTargetLabel = computed<string>(() => {
|
||||
const resolution = targetResolution.value
|
||||
return knownLabels[selectedTargetId.value] ?? (resolution?.kind === 'ref' ? resolution.name : selectedTargetId.value)
|
||||
})
|
||||
|
||||
function selectTarget(id: string, label: string): void {
|
||||
knownLabels[id] = label
|
||||
@@ -46,7 +72,10 @@ function selectTarget(id: string, label: string): void {
|
||||
<div class="flex flex-col gap-6">
|
||||
<TargetsView @select="selectTarget" />
|
||||
|
||||
<TargetCallers v-if="selectedTargetId" :target="selectedTargetId" :title="`Callers of ${selectedTargetLabel}`" />
|
||||
<TargetCallers v-if="targetResolution?.kind === 'ref'" :target="targetResolution.ref" :title="`Callers of ${selectedTargetLabel}`" />
|
||||
<Alert v-else-if="targetResolution?.kind === 'notice'" variant="destructive">
|
||||
<AlertDescription>{{ targetResolution.message }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<TargetCallers title="Top callers, across every target" />
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,8 @@ describe('useConsumersStore.fetchUserDetail', () => {
|
||||
expect(paths.some((p) => p.includes('scope=user%3Aalice') && p.includes('metric=req'))).toBe(true)
|
||||
expect(paths.some((p) => p.includes('scope=user%3Aalice') && p.includes('metric=cost'))).toBe(true)
|
||||
expect(store.detail.alice.loading).toBe(false)
|
||||
expect(store.detail.alice.error).toBe('')
|
||||
expect(store.detail.alice.reqError).toBe('')
|
||||
expect(store.detail.alice.costError).toBe('')
|
||||
expect(store.detail.alice.reqSeries).toEqual(emptySeries)
|
||||
expect(store.detail.alice.costSeries).toEqual(emptySeries)
|
||||
})
|
||||
@@ -181,8 +182,139 @@ describe('useConsumersStore.fetchUserDetail', () => {
|
||||
mockedAdminFetch.mockRejectedValue(new Error('boom'))
|
||||
await store.fetchUserDetail('bob', 'day', 7, false)
|
||||
|
||||
expect(store.detail.alice.error).toBe('')
|
||||
expect(store.detail.alice.reqError).toBe('')
|
||||
expect(store.detail.alice.costError).toBe('')
|
||||
expect(store.detail.alice.reqSeries).toEqual(emptySeries)
|
||||
expect(store.detail.bob.error).toBe('boom')
|
||||
expect(store.detail.bob.reqError).toBe('boom')
|
||||
expect(store.detail.bob.costError).toBe('boom')
|
||||
})
|
||||
|
||||
// verify-ui-states.md #4: a failure in only ONE of the three parallel
|
||||
// requests must set only THAT field's own error, never a shared field
|
||||
// the req/cost charts both read — an unrelated modelTotals failure used
|
||||
// to replace two perfectly valid, already-loaded charts with ErrorState.
|
||||
it('scopes each request\'s failure to its own error field, never a shared one', async () => {
|
||||
mockedAdminFetch.mockImplementation((async (path: string) => {
|
||||
if (path.includes('kind=usermodel')) throw new Error('usermodel boom')
|
||||
return emptySeries
|
||||
}) as typeof adminFetch)
|
||||
const store = useConsumersStore()
|
||||
await store.fetchUserDetail('alice', 'day', 7, true)
|
||||
|
||||
expect(store.detail.alice.reqError).toBe('')
|
||||
expect(store.detail.alice.costError).toBe('')
|
||||
expect(store.detail.alice.modelError).toBe('usermodel boom')
|
||||
expect(store.detail.alice.reqSeries).toEqual(emptySeries)
|
||||
expect(store.detail.alice.costSeries).toEqual(emptySeries)
|
||||
})
|
||||
|
||||
// verify-ui-states.md #4: re-fetching the SAME range (a retry, or the
|
||||
// featuresUserModelStats-triggered follow-up UserDetail.vue's own watch
|
||||
// fires) must keep the previous series in `detail` while `loading` is
|
||||
// true, so the caller's own `loading && !reqSeries` skeleton condition
|
||||
// does not flip back on over data already on screen — this is also
|
||||
// exactly what makes re-opening a cached user show its data immediately.
|
||||
it('keeps the previous series while loading is true for a SAME-range refetch', async () => {
|
||||
mockedAdminFetch.mockResolvedValue(emptySeries)
|
||||
const store = useConsumersStore()
|
||||
await store.fetchUserDetail('alice', 'day', 7, false)
|
||||
expect(store.detail.alice.reqSeries).toEqual(emptySeries)
|
||||
|
||||
// Second call never resolves within this assertion window — read
|
||||
// `detail.alice` synchronously right after firing it, before either
|
||||
// of the two (req, cost) in-flight fetches settle, to inspect the
|
||||
// mid-flight state. Both pending calls share this one mock
|
||||
// implementation, so every resolver gets collected and released.
|
||||
const resolvers: (() => void)[] = []
|
||||
mockedAdminFetch.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvers.push(() => resolve(emptySeries))
|
||||
}),
|
||||
)
|
||||
const inFlight = store.fetchUserDetail('alice', 'day', 7, false)
|
||||
expect(store.detail.alice.loading).toBe(true)
|
||||
expect(store.detail.alice.reqSeries).toEqual(emptySeries)
|
||||
|
||||
resolvers.forEach((resolve) => resolve())
|
||||
await inFlight
|
||||
})
|
||||
|
||||
// verify-ui-states.md #4: a genuine RANGE change for the same user
|
||||
// clears the previous range's own series before the new fetch starts,
|
||||
// so a stale (wrong-range) chart never sits under the new heading.
|
||||
it('clears the previous series when the range genuinely changes', async () => {
|
||||
mockedAdminFetch.mockResolvedValue(emptySeries)
|
||||
const store = useConsumersStore()
|
||||
await store.fetchUserDetail('alice', 'day', 7, false)
|
||||
expect(store.detail.alice.reqSeries).toEqual(emptySeries)
|
||||
|
||||
const resolvers: (() => void)[] = []
|
||||
mockedAdminFetch.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvers.push(() => resolve(emptySeries))
|
||||
}),
|
||||
)
|
||||
const inFlight = store.fetchUserDetail('alice', 'day', 14, false)
|
||||
expect(store.detail.alice.loading).toBe(true)
|
||||
expect(store.detail.alice.reqSeries).toBeNull()
|
||||
expect(store.detail.alice.costSeries).toBeNull()
|
||||
|
||||
resolvers.forEach((resolve) => resolve())
|
||||
await inFlight
|
||||
})
|
||||
|
||||
// verify-ui-states-2.md #7: a genuine RANGE change must also clear the
|
||||
// PREVIOUS range's own per-section errors, not just its series/totals —
|
||||
// otherwise loadState's own "error beats loading" precedence keeps the
|
||||
// old range's ErrorState on screen under the new range's heading instead
|
||||
// of the skeleton the cleared reqSeries/costSeries/modelTotals is meant
|
||||
// to produce.
|
||||
it('clears reqError/costError/modelError when the range genuinely changes', async () => {
|
||||
mockedAdminFetch.mockImplementation((async (path: string) => {
|
||||
if (path.includes('kind=usermodel')) throw new Error('usermodel boom')
|
||||
throw new Error('series boom')
|
||||
}) as typeof adminFetch)
|
||||
const store = useConsumersStore()
|
||||
await store.fetchUserDetail('alice', 'day', 7, true)
|
||||
expect(store.detail.alice.reqError).toBe('series boom')
|
||||
expect(store.detail.alice.costError).toBe('series boom')
|
||||
expect(store.detail.alice.modelError).toBe('usermodel boom')
|
||||
|
||||
const resolvers: (() => void)[] = []
|
||||
mockedAdminFetch.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvers.push(() => resolve(emptySeries))
|
||||
}),
|
||||
)
|
||||
const inFlight = store.fetchUserDetail('alice', 'day', 14, true)
|
||||
expect(store.detail.alice.reqError).toBe('')
|
||||
expect(store.detail.alice.costError).toBe('')
|
||||
expect(store.detail.alice.modelError).toBe('')
|
||||
|
||||
resolvers.forEach((resolve) => resolve())
|
||||
await inFlight
|
||||
})
|
||||
|
||||
it('keeps the previous errors on a SAME-range refetch (a retry) — only a genuine range change clears them', async () => {
|
||||
mockedAdminFetch.mockRejectedValue(new Error('boom'))
|
||||
const store = useConsumersStore()
|
||||
await store.fetchUserDetail('alice', 'day', 7, false)
|
||||
expect(store.detail.alice.reqError).toBe('boom')
|
||||
|
||||
const resolvers: (() => void)[] = []
|
||||
mockedAdminFetch.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvers.push(() => resolve(emptySeries))
|
||||
}),
|
||||
)
|
||||
const inFlight = store.fetchUserDetail('alice', 'day', 7, false)
|
||||
expect(store.detail.alice.reqError).toBe('boom')
|
||||
|
||||
resolvers.forEach((resolve) => resolve())
|
||||
await inFlight
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,10 +8,27 @@ import type { AdminConsumersResponse, AdminSeriesResponse, AdminTotalsResponse,
|
||||
/** REFRESH_STALE_MS is how long a previously-fetched /admin/api/consumers response is trusted before ConsumersPage.vue's mount triggers a fresh fetch — this endpoint reflects the operator's own config (users/groups/access lists), which changes on a config edit, not every 5s like the polled dashboard store. A 60s staleness window (matches Q12's "new endpoints on demand or 60s" DECISION) means switching between pages within the Consumers tab never re-fetches on every mount, while a long-open session still notices a config reload eventually. */
|
||||
const REFRESH_STALE_MS = 60_000
|
||||
|
||||
/** UserDetailState is one user's on-demand detail — UserDetail.vue's own data (timeline series, per-model totals), fetched only when that user's row is actually opened, keyed by user id so switching between two already-opened users never re-fetches. */
|
||||
/**
|
||||
* UserDetailState is one user's on-demand detail — UserDetail.vue's own
|
||||
* data (timeline series, per-model totals), fetched only when that
|
||||
* user's row is actually opened, keyed by user id so switching between
|
||||
* two already-opened users never re-fetches.
|
||||
*
|
||||
* `reqError`/`costError`/`modelError` are THREE independent fields
|
||||
* (verify-ui-states.md #4 fix — previously one shared `error`, joined
|
||||
* from all three requests): a failure in only ONE of the three
|
||||
* Promise.allSettled calls (say, the usermodel totals) used to set that
|
||||
* one shared field, and UserDetail.vue's template read it for BOTH the
|
||||
* "Requests over time" and "Cost over time" cards — an unrelated
|
||||
* modelTotals failure replaced two perfectly valid, already-loaded
|
||||
* charts with ErrorState. Each chart/table now reads only its own field.
|
||||
*/
|
||||
export interface UserDetailState {
|
||||
loading: boolean
|
||||
error: string
|
||||
reqError: string
|
||||
costError: string
|
||||
/** '' whenever modelTotalsUnavailable is true (a 404 is an expected, named state — modelTotalsUnavailable below, not a generic error) or the fetch was skipped (userModelStatsEnabled false). */
|
||||
modelError: string
|
||||
reqSeries: AdminSeriesResponse | null
|
||||
costSeries: AdminSeriesResponse | null
|
||||
/** null while userModelStats is off (no fetch attempted), the 404 case (see modelTotalsUnavailable), or before the first fetch resolves. */
|
||||
@@ -21,22 +38,29 @@ export interface UserDetailState {
|
||||
fetchedAt: number | null
|
||||
/** reqId guards against an out-of-order response overwriting a newer one (latest-request-wins) — two ranges in flight for the same user can otherwise land out of order. */
|
||||
reqId: number
|
||||
/** The (window, span) this state's OWN reqSeries/costSeries/modelTotals were fetched for, or null before the first fetch ever starts — fetchUserDetail's own "is this a genuine range change, not just a same-range refetch" check (verify-ui-states.md #4 fix), see that action's own doc comment. */
|
||||
window: HistoryWindow | null
|
||||
span: number | null
|
||||
}
|
||||
|
||||
function emptyDetail(): UserDetailState {
|
||||
return {
|
||||
loading: false,
|
||||
error: '',
|
||||
reqError: '',
|
||||
costError: '',
|
||||
modelError: '',
|
||||
reqSeries: null,
|
||||
costSeries: null,
|
||||
modelTotals: null,
|
||||
modelTotalsUnavailable: false,
|
||||
fetchedAt: null,
|
||||
reqId: 0,
|
||||
window: null,
|
||||
span: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** settledError reads one Error message off a rejected PromiseSettledResult, or '' for a fulfilled one — fetchUserDetail's own small helper for building a combined error message out of whichever of its three independent requests failed. */
|
||||
/** settledError reads one Error message off a rejected PromiseSettledResult, or '' for a fulfilled one. */
|
||||
function settledError(result: PromiseSettledResult<unknown>): string {
|
||||
if (result.status !== 'rejected') return ''
|
||||
return result.reason instanceof Error ? result.reason.message : String(result.reason)
|
||||
@@ -58,10 +82,20 @@ export const useConsumersStore = defineStore('consumers', {
|
||||
detail: {} as Record<string, UserDetailState>,
|
||||
}),
|
||||
actions: {
|
||||
/** fetchConsumers fetches GET /admin/api/consumers unconditionally — callers that want the staleness guard use ensureConsumers instead. */
|
||||
/**
|
||||
* fetchConsumers fetches GET /admin/api/consumers unconditionally —
|
||||
* callers that want the staleness guard use ensureConsumers instead.
|
||||
* The `this.loading` in-flight guard (verify-ui-states.md #11 fix)
|
||||
* matches stores/dashboard.ts's/stores/events.ts's own identical
|
||||
* guard on their refresh() — without it, AccessMatrix.vue's own
|
||||
* ErrorState Retry button (wired directly to this action) could fire
|
||||
* a second overlapping request on a rapid double-click, free to
|
||||
* resolve in either order.
|
||||
*/
|
||||
async fetchConsumers(): Promise<void> {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated) return
|
||||
if (this.loading) return
|
||||
this.loading = true
|
||||
try {
|
||||
this.data = await adminFetch<AdminConsumersResponse>('/admin/api/consumers')
|
||||
@@ -106,7 +140,34 @@ export const useConsumersStore = defineStore('consumers', {
|
||||
if (!auth.isAuthenticated) return
|
||||
const existing = this.detail[userId] ?? emptyDetail()
|
||||
const requestId = existing.reqId + 1
|
||||
this.detail[userId] = { ...existing, loading: true, reqId: requestId }
|
||||
// A genuine RANGE change for this same user (verify-ui-states.md #4
|
||||
// fix) clears the previous range's own series/totals before the
|
||||
// fetch starts — UserDetail.vue's own skeleton condition (`loading
|
||||
// && !reqSeries`) then shows a skeleton instead of the WRONG
|
||||
// range's numbers sitting under the new heading until the fresh
|
||||
// response lands. A SAME-range refetch (re-opening this cached
|
||||
// user, a retry, or the featuresUserModelStats-triggered follow-up
|
||||
// UserDetail.vue's own watch fires) is NOT a selection change —
|
||||
// `existing.window === null` (never fetched before) also takes this
|
||||
// branch, which is harmless since there is nothing to clear yet.
|
||||
const rangeChanged = existing.window !== null && (existing.window !== window || existing.span !== span)
|
||||
this.detail[userId] = {
|
||||
...existing,
|
||||
loading: true,
|
||||
reqId: requestId,
|
||||
reqSeries: rangeChanged ? null : existing.reqSeries,
|
||||
costSeries: rangeChanged ? null : existing.costSeries,
|
||||
modelTotals: rangeChanged ? null : existing.modelTotals,
|
||||
// verify-ui-states-2.md #7: a genuine range change also clears the
|
||||
// PREVIOUS range's own per-section errors — without this, an old
|
||||
// range's ErrorState (loadState's own "error beats loading"
|
||||
// precedence) stayed on screen under the new range's heading until
|
||||
// the fresh response landed, instead of the skeleton a cleared
|
||||
// reqSeries/costSeries/modelTotals is meant to produce.
|
||||
reqError: rangeChanged ? '' : existing.reqError,
|
||||
costError: rangeChanged ? '' : existing.costError,
|
||||
modelError: rangeChanged ? '' : existing.modelError,
|
||||
}
|
||||
|
||||
const clamped = dayOrMonthWindow(window, span)
|
||||
const [reqResult, costResult, modelResult] = await Promise.allSettled([
|
||||
@@ -132,27 +193,23 @@ export const useConsumersStore = defineStore('consumers', {
|
||||
modelResult.status === 'rejected' && modelResult.reason instanceof AdminApiError && modelResult.reason.status === 404
|
||||
|
||||
const current = this.detail[userId] ?? emptyDetail()
|
||||
// A 404 on the usermodel call is a known, EXPECTED state (the
|
||||
// feature is off server-side) — surfaced via modelTotalsUnavailable
|
||||
// instead, never folded into the generic error message. Deduped
|
||||
// (Set) — the req and cost calls commonly fail with the IDENTICAL
|
||||
// message (e.g. the same network error, or the same 503 "usage
|
||||
// store unavailable"), and repeating it twice reads as noise, not
|
||||
// two distinct problems.
|
||||
const errorMessages = new Set(
|
||||
[settledError(reqResult), settledError(costResult), modelTotalsUnavailable ? '' : settledError(modelResult)].filter(Boolean),
|
||||
)
|
||||
const combinedError = Array.from(errorMessages).join('; ')
|
||||
|
||||
this.detail[userId] = {
|
||||
loading: false,
|
||||
error: combinedError,
|
||||
// Per-section errors (verify-ui-states.md #4 fix) — see this
|
||||
// state's own doc comment. A 404 on the usermodel call is a
|
||||
// known, EXPECTED state (the feature is off server-side),
|
||||
// surfaced via modelTotalsUnavailable instead, never as an error.
|
||||
reqError: settledError(reqResult),
|
||||
costError: settledError(costResult),
|
||||
modelError: modelTotalsUnavailable ? '' : settledError(modelResult),
|
||||
reqSeries: reqResult.status === 'fulfilled' ? reqResult.value : current.reqSeries,
|
||||
costSeries: costResult.status === 'fulfilled' ? costResult.value : current.costSeries,
|
||||
modelTotals: modelResult.status === 'fulfilled' ? modelResult.value : modelTotalsUnavailable ? null : current.modelTotals,
|
||||
modelTotalsUnavailable,
|
||||
fetchedAt: Date.now(),
|
||||
reqId: requestId,
|
||||
window,
|
||||
span,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastsStore } from '@/stores/toasts'
|
||||
import type { AdminOverviewResponse, AdminTargetsResponse, AdminUsageResponse } from '@/types/api'
|
||||
|
||||
/** How often the Providers/Usage/Targets views poll (matches the replaced vanilla-JS page). */
|
||||
@@ -39,6 +40,8 @@ export const useDashboardStore = defineStore('dashboard', {
|
||||
* so the dashboard never resorts to comparing response ordering.
|
||||
*/
|
||||
refreshing: false,
|
||||
/** toastedForFailure guards states-plan.md item 3's "one toast per failure streak until it recovers" — set once a background-refresh-failure toast has fired for the CURRENT ongoing failure streak, reset the moment a refresh fully succeeds again (failures.length === 0 in doRefresh). */
|
||||
toastedForFailure: false,
|
||||
}),
|
||||
actions: {
|
||||
async refresh(): Promise<void> {
|
||||
@@ -54,6 +57,16 @@ export const useDashboardStore = defineStore('dashboard', {
|
||||
},
|
||||
/** doRefresh is refresh()'s actual body, split out so the in-flight guard above wraps it in one place rather than every early-return branch below needing its own `finally`. */
|
||||
async doRefresh(): Promise<void> {
|
||||
// Captured BEFORE this tick's results are assigned below — overview/
|
||||
// usage/targets are never cleared on a failed fetch (only ever
|
||||
// assigned on a FULFILLED result), so this is a true "did the reader
|
||||
// already have something on screen before this attempt" read, not
|
||||
// just "did the previous refresh fully succeed" (a first attempt
|
||||
// that partially succeeds still advances lastUpdated below, which
|
||||
// would otherwise make a same-tick check on `lastUpdated` see this
|
||||
// attempt's own partial success instead of the PRIOR state).
|
||||
const hadDataBefore = this.overview !== null || this.usage !== null || this.targets !== null
|
||||
|
||||
const [overviewResult, usageResult, targetsResult] = await Promise.allSettled([
|
||||
adminFetch<AdminOverviewResponse>('/admin/api/overview'),
|
||||
adminFetch<AdminUsageResponse>('/admin/api/usage'),
|
||||
@@ -78,6 +91,7 @@ export const useDashboardStore = defineStore('dashboard', {
|
||||
if (failures.length === 0) {
|
||||
this.lastUpdated = new Date()
|
||||
this.error = ''
|
||||
this.toastedForFailure = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -96,6 +110,19 @@ export const useDashboardStore = defineStore('dashboard', {
|
||||
this.error = failures
|
||||
.map(({ label, reason }) => `${label}: ${reason instanceof Error ? reason.message : String(reason)}`)
|
||||
.join('; ')
|
||||
|
||||
// states-plan.md item 3: a background refresh failure while data is
|
||||
// ALREADY shown gets a toast instead of wiping content (the header's
|
||||
// own inline `error` text already covers that case too, but a toast
|
||||
// surfaces it even when the reader is looking at a different page
|
||||
// than the one showing the stale data) — never fired on a genuine
|
||||
// first-load failure (hadDataBefore false), which is an ErrorState's
|
||||
// job instead, and never repeated on every tick of an ONGOING
|
||||
// failure streak (toastedForFailure).
|
||||
if (hadDataBefore && !this.toastedForFailure) {
|
||||
useToastsStore().push({ kind: 'error', message: `Dashboard refresh failed: ${this.error}` })
|
||||
}
|
||||
this.toastedForFailure = true
|
||||
},
|
||||
/**
|
||||
* startPolling is idempotent and safe to call before a key is stored —
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastsStore } from '@/stores/toasts'
|
||||
import type { AdminEventsResponse, AdminEventView } from '@/types/api'
|
||||
|
||||
/** How often the Events view polls while mounted — same cadence as the Providers/Usage/Targets dashboard poll (stores/dashboard.ts's own POLL_MS): a rate-limit/upstream event is exactly the kind of thing an operator wants to see within a few seconds, not thirty. */
|
||||
@@ -57,6 +58,8 @@ export const useEventsStore = defineStore('events', {
|
||||
poller: undefined as VisibilityPoller | undefined,
|
||||
/** Mirrors dashboard.ts's own in-flight guard: a round trip slower than POLL_MS must never stack a second refresh() on top of the first. */
|
||||
refreshing: false,
|
||||
/** toastedForFailure mirrors dashboard.ts's own flag: states-plan.md item 3's "one toast per failure streak per store until it recovers". */
|
||||
toastedForFailure: false,
|
||||
}),
|
||||
actions: {
|
||||
async refresh(): Promise<void> {
|
||||
@@ -64,6 +67,12 @@ export const useEventsStore = defineStore('events', {
|
||||
if (!auth.isAuthenticated) return
|
||||
if (this.refreshing) return
|
||||
this.refreshing = true
|
||||
// Captured before this tick's own result lands — see dashboard.ts's
|
||||
// doRefresh's identical hadDataBefore doc comment: `error` alone is
|
||||
// stale-friendly (a previous failure leaves it set), but `lastUpdated`
|
||||
// is null exactly until the FIRST successful fetch ever completes,
|
||||
// regardless of how many failures came before it.
|
||||
const hadDataBefore = this.lastUpdated !== null
|
||||
try {
|
||||
const res = await adminFetch<AdminEventsResponse>(`/admin/api/events?limit=${EVENTS_LIMIT}`)
|
||||
this.events = res.events
|
||||
@@ -73,12 +82,20 @@ export const useEventsStore = defineStore('events', {
|
||||
this.degraded = res.degraded ?? false
|
||||
this.lastUpdated = new Date()
|
||||
this.error = ''
|
||||
this.toastedForFailure = false
|
||||
} catch (err) {
|
||||
// A 401/403 already rejected the key (lib/api.ts) — the AuthGate
|
||||
// takes over the whole view, matching dashboard.ts's own
|
||||
// identical carve-out; nothing left to report here.
|
||||
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
|
||||
this.error = err instanceof Error ? err.message : String(err)
|
||||
// states-plan.md item 3 — see dashboard.ts's doRefresh for the
|
||||
// full reasoning: only when the reader already had a successful
|
||||
// fetch to show, and only once per ongoing failure streak.
|
||||
if (hadDataBefore && !this.toastedForFailure) {
|
||||
useToastsStore().push({ kind: 'error', message: `Events refresh failed: ${this.error}` })
|
||||
}
|
||||
this.toastedForFailure = true
|
||||
} finally {
|
||||
this.refreshing = false
|
||||
}
|
||||
|
||||
@@ -132,6 +132,25 @@ describe('useReliabilityStore.fetch', () => {
|
||||
expect(reliability.error).toBe('')
|
||||
})
|
||||
|
||||
// Live-preview fix: dashboard.overview being null means the fleet's
|
||||
// provider list is UNKNOWN, not genuinely zero — fetch() must not resolve
|
||||
// as "loaded, empty" (the bug above's own short-circuit used to do this
|
||||
// BEFORE dashboard.overview had ever landed too), or ReliabilityPage.vue's
|
||||
// per-provider charts briefly render an empty grid on a cold reload
|
||||
// instead of staying on their skeleton.
|
||||
it('no-ops with no adminFetch calls and leaves state untouched when dashboard.overview has not loaded yet', async () => {
|
||||
// useDashboardStore().overview defaults to null — never assigned here.
|
||||
const reliability = useReliabilityStore()
|
||||
|
||||
await reliability.fetch()
|
||||
|
||||
expect(mockedAdminFetch).not.toHaveBeenCalled()
|
||||
expect(reliability.buckets).toEqual([])
|
||||
expect(reliability.lastUpdated).toBeNull()
|
||||
expect(reliability.error).toBe('')
|
||||
expect(reliability.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('fetches attempt/fail/timeout/fover per configured provider scope, plus total rej/r402, and assigns every field', async () => {
|
||||
useDashboardStore().overview = providerOverview(['openai', 'anthropic'])
|
||||
const reliability = useReliabilityStore()
|
||||
|
||||
@@ -119,12 +119,27 @@ export const useReliabilityStore = defineStore('reliability', {
|
||||
}),
|
||||
actions: {
|
||||
async fetch(): Promise<void> {
|
||||
const dashboard = useDashboardStore()
|
||||
/**
|
||||
* dashboard.overview not having loaded yet (live-preview fix) means
|
||||
* the fleet's provider list is UNKNOWN, not genuinely zero — a
|
||||
* no-op here, rather than falling into the zero-providers
|
||||
* short-circuit below, which used to resolve `lastUpdated`/`buckets`
|
||||
* as "loaded, empty" before the dashboard's own first poll had even
|
||||
* landed, briefly rendering an empty chart grid on a cold reload
|
||||
* instead of a skeleton. ReliabilityPage.vue's own watch on
|
||||
* `dashboard.overview?.providers.length` re-fires this fetch the
|
||||
* moment overview actually loads, so nothing is lost by skipping it
|
||||
* here — this.loading/buckets/error are left exactly as they were.
|
||||
*/
|
||||
const overview = dashboard.overview
|
||||
if (overview === null) return
|
||||
|
||||
const requestId = ++this.reqId
|
||||
this.loading = true
|
||||
try {
|
||||
const filters = useFiltersStore()
|
||||
const dashboard = useDashboardStore()
|
||||
const providerNames = (dashboard.overview?.providers ?? []).map((p) => p.name)
|
||||
const providerNames = overview.providers.map((p) => p.name)
|
||||
|
||||
if (providerNames.length === 0) {
|
||||
if (requestId !== this.reqId) return
|
||||
|
||||
@@ -86,6 +86,44 @@ describe('useSpendStore.fetchModelRanking', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// verify-ui-states-3.md pre-existing fix: fetchModelRanking's failures used
|
||||
// to fold into breakdownError, which a later, unrelated fetchBreakdown
|
||||
// success then cleared unconditionally — silently wiping a real ranking
|
||||
// failure. rankingError is its own field, never touched by fetchBreakdown.
|
||||
describe('useSpendStore.fetchModelRanking: rankingError', () => {
|
||||
it('sets rankingError on failure, and a later breakdown success never clears it', async () => {
|
||||
mockRoutes({
|
||||
models: () => {
|
||||
throw new Error('ranking boom')
|
||||
},
|
||||
series: () => seriesFor(['total'], 5),
|
||||
})
|
||||
const spend = useSpendStore()
|
||||
await spend.fetchModelRanking()
|
||||
expect(spend.rankingError).toBe('ranking boom')
|
||||
expect(spend.breakdownError).toBe('')
|
||||
|
||||
await spend.fetchBreakdown() // succeeds even off a still-empty ranking (by=model with 0 ids just charts nothing)
|
||||
expect(spend.breakdownError).toBe('')
|
||||
expect(spend.rankingError).toBe('ranking boom') // NOT cross-cleared
|
||||
})
|
||||
|
||||
it('clears rankingError on the next successful ranking fetch', async () => {
|
||||
mockRoutes({
|
||||
models: () => {
|
||||
throw new Error('ranking boom')
|
||||
},
|
||||
})
|
||||
const spend = useSpendStore()
|
||||
await spend.fetchModelRanking()
|
||||
expect(spend.rankingError).toBe('ranking boom')
|
||||
|
||||
mockRoutes({ models: () => rankingWith(['a/one']) })
|
||||
await spend.fetchModelRanking()
|
||||
expect(spend.rankingError).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// P3 item 21: PricingHealthTable.vue's "unpriced first" ordering needs a
|
||||
// ranking sorted by TRAFFIC (req), independent of whatever metric the
|
||||
// Spend breakdown chart currently sorts by — sorting by `cost` (the
|
||||
@@ -251,6 +289,59 @@ describe('useSpendStore.fetchBreakdown: comparison', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// verify-ui-states-3.md NEW-1: a genuine selection change (by, metric,
|
||||
// window, span, or scope) clears `buckets` SYNCHRONOUSLY, before the new
|
||||
// fetch resolves — SpendPage.vue's breakdownState (hasData: buckets.length
|
||||
// > 0) must flip to 'skeleton' immediately, never keep the OLD selection's
|
||||
// series on screen mislabeled under the NEW selection. A same-selection
|
||||
// refetch (a poll tick, or a Retry) must leave `buckets` alone.
|
||||
describe('useSpendStore.fetchBreakdown: NEW-1 buckets-clear-on-selection-change', () => {
|
||||
it('clears buckets synchronously on a genuine selection change, before the fetch resolves', async () => {
|
||||
mockRoutes({ models: () => rankingWith([]), series: () => seriesFor(['total'], 5) })
|
||||
const spend = useSpendStore()
|
||||
await spend.fetchModelRanking()
|
||||
await spend.fetchBreakdown()
|
||||
expect(spend.buckets).toEqual(['20260922', '20260923'])
|
||||
|
||||
spend.$patch({ metric: 'req' }) // a genuine selection change
|
||||
const pending = spend.fetchBreakdown()
|
||||
expect(spend.buckets).toEqual([]) // cleared before the network call even starts
|
||||
await pending
|
||||
expect(spend.buckets).toEqual(['20260922', '20260923']) // repopulated once the new selection's fetch lands
|
||||
})
|
||||
|
||||
it('leaves buckets on screen across a same-selection refetch (a poll tick)', async () => {
|
||||
mockRoutes({ models: () => rankingWith([]), series: () => seriesFor(['total'], 5) })
|
||||
const spend = useSpendStore()
|
||||
await spend.fetchModelRanking()
|
||||
await spend.fetchBreakdown()
|
||||
expect(spend.buckets).toEqual(['20260922', '20260923'])
|
||||
|
||||
const pending = spend.fetchBreakdown() // same by/metric/window/span/scope
|
||||
expect(spend.buckets).toEqual(['20260922', '20260923']) // NOT cleared
|
||||
await pending
|
||||
expect(spend.buckets).toEqual(['20260922', '20260923'])
|
||||
})
|
||||
|
||||
it('a same-selection refetch failure sets breakdownError but leaves buckets on screen (the "ready" state the inline error line in SpendPage.vue depends on)', async () => {
|
||||
mockRoutes({ models: () => rankingWith([]), series: () => seriesFor(['total'], 5) })
|
||||
const spend = useSpendStore()
|
||||
await spend.fetchModelRanking()
|
||||
await spend.fetchBreakdown()
|
||||
expect(spend.buckets.length).toBeGreaterThan(0)
|
||||
expect(spend.breakdownError).toBe('')
|
||||
|
||||
mockedAdminFetch.mockImplementation((async (path: string) => {
|
||||
if (path.startsWith('/admin/api/usage/series')) throw new Error('breakdown boom')
|
||||
return rankingWith([])
|
||||
}) as typeof adminFetch)
|
||||
await spend.fetchBreakdown()
|
||||
|
||||
expect(spend.breakdownError).toBe('breakdown boom')
|
||||
expect(spend.buckets.length).toBeGreaterThan(0) // still on screen: loadState reads 'ready', not 'error'
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSpendStore.fetchBurndown', () => {
|
||||
it('fetches a day-window total series spanning the current UTC day-of-month', async () => {
|
||||
mockRoutes({
|
||||
@@ -297,6 +388,32 @@ describe('useSpendStore.fetchBurndown', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// verify-ui-states-3.md NEW-2: the scope===null (provider-scope) branch
|
||||
// must also reset burndownError/burndownLoading, not just buckets/points/
|
||||
// budget — otherwise a stale error/loading from a PREVIOUS scope's failed
|
||||
// fetch survives a detour through a provider scope (chart hidden) and
|
||||
// reappears once the reader switches back to a scope with a chart.
|
||||
describe('useSpendStore.fetchBurndown: NEW-2 provider-scope reset', () => {
|
||||
it('resets burndownError and burndownLoading when scope narrows to a provider', async () => {
|
||||
mockRoutes({
|
||||
series: () => {
|
||||
throw new Error('burndown boom')
|
||||
},
|
||||
})
|
||||
const spend = useSpendStore()
|
||||
await spend.fetchBurndown() // fails against the default 'all' scope
|
||||
expect(spend.burndownError).toBe('burndown boom')
|
||||
|
||||
useFiltersStore().setFilters({ scope: 'provider:openai' })
|
||||
await spend.fetchBurndown() // burndownAvailable is now false -> scope===null branch
|
||||
expect(spend.burndownError).toBe('')
|
||||
expect(spend.burndownLoading).toBe(false)
|
||||
expect(spend.burndownBuckets).toEqual([])
|
||||
expect(spend.burndownPoints).toEqual([])
|
||||
expect(spend.burndownBudgetMicros).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSpendStore.fetchDrilldown', () => {
|
||||
it('requests kind=group at the top level (drill empty)', async () => {
|
||||
mockRoutes({
|
||||
@@ -375,7 +492,7 @@ describe('useSpendStore.fetchDrilldown', () => {
|
||||
spend.$patch({ drill: 'user:alice' })
|
||||
await spend.fetchDrilldown()
|
||||
expect(spend.drilldownDisabled).toBe(true)
|
||||
expect(spend.error).toBe('')
|
||||
expect(spend.drilldownError).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -398,3 +515,192 @@ describe('useSpendStore.setSelection', () => {
|
||||
expect(refreshSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// verify-ui-states-2.md #3: breakdownError/pricingHealthError/drilldownError/
|
||||
// burndownError replace one shared `error` field — each leg's own failure
|
||||
// must never surface under, or get silently cleared by, an unrelated leg.
|
||||
describe('useSpendStore: per-request error fields', () => {
|
||||
it('a pricing-health failure sets only pricingHealthError, leaving the other three legs at \'\'', async () => {
|
||||
mockRoutes({
|
||||
models: (url) => {
|
||||
if (url.includes('metric=req')) throw new Error('pricing boom')
|
||||
return rankingWith([])
|
||||
},
|
||||
})
|
||||
const spend = useSpendStore()
|
||||
await spend.refresh()
|
||||
|
||||
expect(spend.pricingHealthError).toBe('pricing boom')
|
||||
expect(spend.breakdownError).toBe('')
|
||||
expect(spend.drilldownError).toBe('')
|
||||
expect(spend.burndownError).toBe('')
|
||||
})
|
||||
|
||||
it('a drilldown failure sets only drilldownError, leaving the other three legs at \'\'', async () => {
|
||||
mockRoutes({
|
||||
totals: () => {
|
||||
throw new Error('drilldown boom')
|
||||
},
|
||||
})
|
||||
const spend = useSpendStore()
|
||||
await spend.refresh()
|
||||
|
||||
expect(spend.drilldownError).toBe('drilldown boom')
|
||||
expect(spend.breakdownError).toBe('')
|
||||
expect(spend.pricingHealthError).toBe('')
|
||||
expect(spend.burndownError).toBe('')
|
||||
})
|
||||
|
||||
it('a later breakdown success never clears an already-set pricingHealthError', async () => {
|
||||
mockRoutes({
|
||||
models: (url) => {
|
||||
if (url.includes('metric=req')) throw new Error('pricing boom')
|
||||
return rankingWith([])
|
||||
},
|
||||
})
|
||||
const spend = useSpendStore()
|
||||
await spend.refresh()
|
||||
expect(spend.pricingHealthError).toBe('pricing boom')
|
||||
|
||||
mockRoutes({}) // every route now succeeds
|
||||
await spend.fetchBreakdown()
|
||||
expect(spend.breakdownError).toBe('')
|
||||
expect(spend.pricingHealthError).toBe('pricing boom')
|
||||
})
|
||||
|
||||
it('retrying only the failing leg clears only that leg\'s own error', async () => {
|
||||
mockRoutes({
|
||||
models: (url) => {
|
||||
if (url.includes('metric=req')) throw new Error('pricing boom')
|
||||
return rankingWith([])
|
||||
},
|
||||
})
|
||||
const spend = useSpendStore()
|
||||
await spend.refresh()
|
||||
expect(spend.pricingHealthError).toBe('pricing boom')
|
||||
|
||||
mockRoutes({}) // the network recovers
|
||||
await spend.fetchPricingHealthRanking()
|
||||
expect(spend.pricingHealthError).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// verify-ui-states-2.md #2: pricingHealthLoaded/drilldownLoaded are "has a
|
||||
// fetch completed for the CURRENT selection", reset only on a genuine
|
||||
// selection change (window/span for pricing health; window/span/drill for
|
||||
// the drilldown) — never on a same-selection refetch (a 60s poll tick),
|
||||
// which must not flash a skeleton back on over an already-settled result.
|
||||
describe('useSpendStore: pricingHealthLoaded / drilldownLoaded', () => {
|
||||
it('pricingHealthLoaded flips true on a successful fetch, even with zero ranked models', async () => {
|
||||
mockRoutes({ models: () => rankingWith([]) })
|
||||
const spend = useSpendStore()
|
||||
expect(spend.pricingHealthLoaded).toBe(false)
|
||||
await spend.fetchPricingHealthRanking()
|
||||
expect(spend.pricingHealthLoaded).toBe(true)
|
||||
})
|
||||
|
||||
it('pricingHealthLoaded stays true across a same-range refetch, and resets before a genuine range change resolves', async () => {
|
||||
mockRoutes({ models: () => rankingWith(['a/one']) })
|
||||
const spend = useSpendStore()
|
||||
await spend.fetchPricingHealthRanking()
|
||||
expect(spend.pricingHealthLoaded).toBe(true)
|
||||
|
||||
// Same range again (a poll tick): must read true DURING the fetch too
|
||||
// — a caller (PricingHealthTable) checking mid-flight must not see a
|
||||
// skeleton flash for an unchanged selection.
|
||||
let sawLoadedDuringSameRangeRefetch: boolean | undefined
|
||||
mockedAdminFetch.mockImplementation((async () => {
|
||||
sawLoadedDuringSameRangeRefetch = spend.pricingHealthLoaded
|
||||
return rankingWith(['a/one'])
|
||||
}) as typeof adminFetch)
|
||||
await spend.fetchPricingHealthRanking()
|
||||
expect(sawLoadedDuringSameRangeRefetch).toBe(true)
|
||||
|
||||
// A genuine range change (span 7 -> 30, still window=day) DOES reset
|
||||
// it before the new fetch resolves.
|
||||
useFiltersStore().setFilters({ range: '30d' })
|
||||
let sawLoadedDuringRangeChange: boolean | undefined
|
||||
mockedAdminFetch.mockImplementation((async () => {
|
||||
sawLoadedDuringRangeChange = spend.pricingHealthLoaded
|
||||
return rankingWith(['a/one'])
|
||||
}) as typeof adminFetch)
|
||||
await spend.fetchPricingHealthRanking()
|
||||
expect(sawLoadedDuringRangeChange).toBe(false)
|
||||
})
|
||||
|
||||
it('drilldownLoaded flips true on a successful fetch and on a 404 (a settled, known-disabled state)', async () => {
|
||||
mockRoutes({ totals: () => ({ kind: 'group', window: 'day', span: 2, offset: 0, metrics: ['cost'], rows: [] }) })
|
||||
const spend = useSpendStore()
|
||||
expect(spend.drilldownLoaded).toBe(false)
|
||||
await spend.fetchDrilldown()
|
||||
expect(spend.drilldownLoaded).toBe(true)
|
||||
|
||||
mockedAdminFetch.mockImplementation((async (path: string) => {
|
||||
if (path.startsWith('/admin/api/usage/totals')) throw new AdminApiError('user-model statistics are not enabled', 404)
|
||||
throw new Error(`unexpected path ${path}`)
|
||||
}) as typeof adminFetch)
|
||||
const spend2 = useSpendStore()
|
||||
spend2.$patch({ drill: 'user:alice' })
|
||||
await spend2.fetchDrilldown()
|
||||
expect(spend2.drilldownDisabled).toBe(true)
|
||||
expect(spend2.drilldownLoaded).toBe(true)
|
||||
})
|
||||
|
||||
it('drilldownLoaded resets when `drill` changes, not on a same-selection refetch', async () => {
|
||||
mockRoutes({ totals: () => ({ kind: 'group', window: 'day', span: 2, offset: 0, metrics: ['cost'], rows: [] }) })
|
||||
const spend = useSpendStore()
|
||||
await spend.fetchDrilldown()
|
||||
expect(spend.drilldownLoaded).toBe(true)
|
||||
|
||||
let sawLoadedDuringSameDrillRefetch: boolean | undefined
|
||||
mockedAdminFetch.mockImplementation((async () => {
|
||||
sawLoadedDuringSameDrillRefetch = spend.drilldownLoaded
|
||||
return { kind: 'group', window: 'day', span: 2, offset: 0, metrics: ['cost'], rows: [] }
|
||||
}) as typeof adminFetch)
|
||||
await spend.fetchDrilldown()
|
||||
expect(sawLoadedDuringSameDrillRefetch).toBe(true)
|
||||
|
||||
spend.$patch({ drill: 'group:friends' })
|
||||
let sawLoadedDuringDrillChange: boolean | undefined
|
||||
mockedAdminFetch.mockImplementation((async () => {
|
||||
sawLoadedDuringDrillChange = spend.drilldownLoaded
|
||||
return { kind: 'user', window: 'day', span: 2, offset: 0, metrics: ['cost'], rows: [] }
|
||||
}) as typeof adminFetch)
|
||||
await spend.fetchDrilldown()
|
||||
expect(sawLoadedDuringDrillChange).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// verify-ui-states-2.md #10: an overlapping poll-tick refresh() and a
|
||||
// selection/filter-change refresh() can resolve in either order — only the
|
||||
// LATEST refresh call may clear `loading`.
|
||||
describe('useSpendStore.refresh: overlapping calls', () => {
|
||||
it('an earlier refresh finishing after a newer one has started does not clear loading', async () => {
|
||||
const spend = useSpendStore()
|
||||
vi.spyOn(spend, 'fetchModelRanking').mockResolvedValue(undefined)
|
||||
|
||||
const pending: (() => void)[] = []
|
||||
const controlled = (): Promise<void> => new Promise((resolve) => pending.push(resolve))
|
||||
vi.spyOn(spend, 'fetchBreakdown').mockImplementation(controlled)
|
||||
vi.spyOn(spend, 'fetchPricingHealthRanking').mockImplementation(controlled)
|
||||
vi.spyOn(spend, 'fetchBurndown').mockImplementation(controlled)
|
||||
vi.spyOn(spend, 'fetchDrilldown').mockImplementation(controlled)
|
||||
|
||||
const firstRefresh = spend.refresh()
|
||||
await vi.waitFor(() => expect(pending.length).toBe(4))
|
||||
const firstPending = pending.splice(0, pending.length)
|
||||
|
||||
const secondRefresh = spend.refresh()
|
||||
await vi.waitFor(() => expect(pending.length).toBe(4))
|
||||
const secondPending = pending.splice(0, pending.length)
|
||||
|
||||
// Resolve only the FIRST (now stale) refresh's own legs.
|
||||
firstPending.forEach((resolve) => resolve())
|
||||
await firstRefresh
|
||||
expect(spend.loading).toBe(true) // the second, newer refresh is still in flight
|
||||
|
||||
secondPending.forEach((resolve) => resolve())
|
||||
await secondRefresh
|
||||
expect(spend.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
+187
-17
@@ -144,13 +144,95 @@ export const useSpendStore = defineStore('spend', {
|
||||
drilldownDisabled: false,
|
||||
|
||||
loading: false,
|
||||
error: '',
|
||||
/**
|
||||
* breakdownError/pricingHealthError/drilldownError/burndownError
|
||||
* (verify-ui-states-2.md #3 fix) replace a single shared `error` field
|
||||
* — that field was cleared ONLY on fetchBreakdown's own success (never
|
||||
* by the other three legs), so a pricing-health/drilldown/burndown
|
||||
* failure that happened to land before a later, unrelated breakdown
|
||||
* success got silently wiped (a false "no traffic" reading over a real
|
||||
* failure), or, the other direction, an already-cleared error from one
|
||||
* leg could sit stale under a DIFFERENT card that loaded fine. Each
|
||||
* card below (SpendPage.vue) now reads and retries only its own field.
|
||||
* fetchModelRanking's own failures used to fold into `breakdownError`
|
||||
* too — see `rankingError` below (verify-ui-states-3.md pre-existing
|
||||
* fix) for why that was also wrong and what replaced it.
|
||||
*/
|
||||
breakdownError: '',
|
||||
/**
|
||||
* rankingError (verify-ui-states-3.md pre-existing-bug fix) is
|
||||
* fetchModelRanking's OWN error field. It used to fold into
|
||||
* `breakdownError`, which a SUBSEQUENT fetchBreakdown success then
|
||||
* cleared unconditionally (line below), silently wiping a real ranking
|
||||
* failure the moment breakdown itself finished fetching — even a
|
||||
* breakdown built from that same still-empty/stale ranking (a cold
|
||||
* by=model load then renders 100% "Other" with no error in sight).
|
||||
* Never touched by fetchBreakdown, so SpendPage.vue can surface it next
|
||||
* to the breakdown chart independently, with no cross-clearing.
|
||||
*/
|
||||
rankingError: '',
|
||||
pricingHealthError: '',
|
||||
drilldownError: '',
|
||||
burndownError: '',
|
||||
/**
|
||||
* pricingHealthLoading/drilldownLoading/burndownLoading (verify-ui-
|
||||
* states.md #3/#7): PricingHealthTable.vue, AttributionDrilldown.vue,
|
||||
* and BurnDownChart's own SpendPage.vue wrapper each need their OWN
|
||||
* in-flight signal — `loading` above only wraps fetchBreakdown (the
|
||||
* by=model/provider/group chart), not these three, which run in
|
||||
* parallel via refresh()'s own Promise.all — so reusing `loading` for
|
||||
* any of them would read false (or wrongly true) independent of
|
||||
* whether THEIR OWN fetch is actually in flight.
|
||||
*/
|
||||
pricingHealthLoading: false,
|
||||
drilldownLoading: false,
|
||||
burndownLoading: false,
|
||||
/**
|
||||
* pricingHealthLoaded/drilldownLoaded (verify-ui-states-2.md #2 fix)
|
||||
* are "has a fetch completed for the CURRENT selection" —
|
||||
* PricingHealthTable.vue's/AttributionDrilldown.vue's own `loaded`
|
||||
* prop — set once their own fetch settles into a genuine result
|
||||
* (success, or a known-disabled 404 for the drilldown), and reset the
|
||||
* moment the selection they depend on changes (see
|
||||
* pricingHealthWindow/pricingHealthSpan and
|
||||
* drilldownWindow/drilldownSpan/drilldownDrill below), so a
|
||||
* genuinely empty table still reads as settled/empty across the 60s
|
||||
* poll's own `loading` flips instead of flashing a skeleton on every
|
||||
* tick.
|
||||
*/
|
||||
pricingHealthLoaded: false,
|
||||
drilldownLoaded: false,
|
||||
/** The (window, span) pricingHealthRanking was last fetched FOR — fetchPricingHealthRanking's own "did the selection this ranking depends on actually change" check, resetting pricingHealthLoaded below exactly when it did. */
|
||||
pricingHealthWindow: null as HistoryWindow | null,
|
||||
pricingHealthSpan: null as number | null,
|
||||
/** The (window, span, drill) drilldownRows were last fetched FOR — fetchDrilldown's own equivalent of the field above. */
|
||||
drilldownWindow: null as HistoryWindow | null,
|
||||
drilldownSpan: null as number | null,
|
||||
drilldownDrillFetchedFor: null as string | null,
|
||||
/**
|
||||
* The (by, metric, window, span, scope) `buckets` was last fetched FOR
|
||||
* (verify-ui-states-3.md NEW-1) — fetchBreakdown's own equivalent of
|
||||
* the two field groups above. A genuine SELECTION change clears
|
||||
* `buckets` synchronously, before the fetch starts, so SpendPage.vue's
|
||||
* breakdownState (hasData: buckets.length > 0) flips to 'skeleton'
|
||||
* immediately instead of leaving the PREVIOUS selection's series on
|
||||
* screen mislabeled under the NEW selection's metric/by/range/scope. A
|
||||
* same-selection refetch (a 60s poll tick, or a Retry) leaves `buckets`
|
||||
* alone — loadState's own 'ready' precedence (lib/load-state.ts).
|
||||
*/
|
||||
breakdownByFetchedFor: null as SpendBy | null,
|
||||
breakdownMetricFetchedFor: null as HistoryMetric | null,
|
||||
breakdownWindow: null as HistoryWindow | null,
|
||||
breakdownSpan: null as number | null,
|
||||
breakdownScope: null as string | null,
|
||||
/** breakdownReqId/rankingReqId/burndownReqId/drilldownReqId are FOUR independent latest-request-wins counters (stores/history.ts's own seriesReqId precedent), one per concern — unlike history.ts's tabs, every one of these fetches concurrently on a single Spend page render, so a single shared counter would make an in-flight ranking fetch spuriously discard a still-relevant breakdown fetch (or vice versa) merely for starting first. */
|
||||
breakdownReqId: 0,
|
||||
rankingReqId: 0,
|
||||
pricingHealthReqId: 0,
|
||||
burndownReqId: 0,
|
||||
drilldownReqId: 0,
|
||||
/** refreshReqId (verify-ui-states-2.md #10 fix) is refresh()'s own latest-request-wins counter over `this.loading` — an overlapping poll-tick refresh and a selection/filter-change refresh can otherwise resolve in either order, and the FIRST to finish clears `loading` while the SECOND is still in flight. */
|
||||
refreshReqId: 0,
|
||||
poller: undefined as VisibilityPoller | undefined,
|
||||
}),
|
||||
getters: {
|
||||
@@ -186,16 +268,37 @@ export const useSpendStore = defineStore('spend', {
|
||||
* concurrently would race fetchBreakdown against a still-in-flight (or
|
||||
* still-empty, on first load) ranking. fetchBurndown/fetchDrilldown
|
||||
* depend on neither and run fully in parallel with that chain.
|
||||
*
|
||||
* `this.loading` is now owned HERE, wrapping the whole
|
||||
* fetchModelRanking-then-fetchBreakdown chain (verify-ui-states.md
|
||||
* live-preview fix), not just fetchBreakdown's own body: SpendPage.
|
||||
* vue's breakdownState (lib/load-state.ts) reads `spend.loading` to
|
||||
* decide skeleton-vs-empty for the breakdown chart, and with the flag
|
||||
* previously scoped to fetchBreakdown alone, it stayed FALSE for the
|
||||
* entire fetchModelRanking leg — live verification caught the
|
||||
* breakdown chart rendering as a bare empty grid (loadState's 'empty'
|
||||
* branch, not 'skeleton') for that whole first leg on every load.
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated) return
|
||||
await Promise.all([
|
||||
this.fetchModelRanking().then(() => this.fetchBreakdown()),
|
||||
this.fetchPricingHealthRanking(),
|
||||
this.fetchBurndown(),
|
||||
this.fetchDrilldown(),
|
||||
])
|
||||
// verify-ui-states-2.md #10: latest-request-wins over `this.loading`
|
||||
// itself — a poll tick and a selection/filter-change refresh can
|
||||
// overlap and resolve in either order; only the LATEST refresh call
|
||||
// is allowed to clear the flag, so an earlier, still-settling one
|
||||
// finishing first never clears it out from under the later one.
|
||||
const requestId = ++this.refreshReqId
|
||||
this.loading = true
|
||||
try {
|
||||
await Promise.all([
|
||||
this.fetchModelRanking().then(() => this.fetchBreakdown()),
|
||||
this.fetchPricingHealthRanking(),
|
||||
this.fetchBurndown(),
|
||||
this.fetchDrilldown(),
|
||||
])
|
||||
} finally {
|
||||
if (requestId === this.refreshReqId) this.loading = false
|
||||
}
|
||||
},
|
||||
async fetchModelRanking(): Promise<void> {
|
||||
const filters = useFiltersStore()
|
||||
@@ -206,32 +309,69 @@ export const useSpendStore = defineStore('spend', {
|
||||
)
|
||||
if (requestId !== this.rankingReqId) return
|
||||
this.modelRanking = res.models
|
||||
this.rankingError = ''
|
||||
} catch (err) {
|
||||
if (requestId !== this.rankingReqId) return
|
||||
if (isAuthRejection(err)) return
|
||||
this.error = messageOf(err)
|
||||
// Own field, never folded into breakdownError — see this store's
|
||||
// own doc comment on rankingError (verify-ui-states-3.md
|
||||
// pre-existing fix).
|
||||
this.rankingError = messageOf(err)
|
||||
}
|
||||
},
|
||||
/** fetchPricingHealthRanking is pricingHealthRanking's own fetch — always metric=req, independent of `this.metric` (see that field's own doc comment, P3 item 21). Runs in parallel with fetchModelRanking in refresh(), not chained after it: neither depends on the other's result. */
|
||||
async fetchPricingHealthRanking(): Promise<void> {
|
||||
const filters = useFiltersStore()
|
||||
// verify-ui-states-2.md #2: a genuine SELECTION change (this ranking
|
||||
// depends only on window/span, not `by`/`metric`/`drill`/scope)
|
||||
// resets pricingHealthLoaded so the new selection's own skeleton
|
||||
// shows — a background poll tick (same window/span) leaves it alone.
|
||||
if (this.pricingHealthWindow !== filters.window || this.pricingHealthSpan !== filters.span) {
|
||||
this.pricingHealthLoaded = false
|
||||
}
|
||||
this.pricingHealthWindow = filters.window
|
||||
this.pricingHealthSpan = filters.span
|
||||
const requestId = ++this.pricingHealthReqId
|
||||
this.pricingHealthLoading = true
|
||||
try {
|
||||
const res = await adminFetch<AdminUsageModelsResponse>(
|
||||
`/admin/api/usage/models?metric=req&window=${filters.window}&span=${filters.span}&limit=${MODEL_RANKING_LIMIT}&detail=1`,
|
||||
)
|
||||
if (requestId !== this.pricingHealthReqId) return
|
||||
this.pricingHealthRanking = res.models
|
||||
this.pricingHealthError = ''
|
||||
this.pricingHealthLoaded = true
|
||||
} catch (err) {
|
||||
if (requestId !== this.pricingHealthReqId) return
|
||||
if (isAuthRejection(err)) return
|
||||
this.error = messageOf(err)
|
||||
this.pricingHealthError = messageOf(err)
|
||||
} finally {
|
||||
if (requestId === this.pricingHealthReqId) this.pricingHealthLoading = false
|
||||
}
|
||||
},
|
||||
async fetchBreakdown(): Promise<void> {
|
||||
const filters = useFiltersStore()
|
||||
// verify-ui-states-3.md NEW-1: a genuine SELECTION change (by,
|
||||
// metric, window, span, or scope) clears `buckets` synchronously,
|
||||
// BEFORE the fetch starts — flips breakdownState to 'skeleton'
|
||||
// immediately instead of leaving the OLD selection's series on
|
||||
// screen mislabeled under the NEW selection. A same-selection
|
||||
// refetch (a 60s poll tick, or a Retry) leaves `buckets` alone.
|
||||
if (
|
||||
this.breakdownByFetchedFor !== this.by ||
|
||||
this.breakdownMetricFetchedFor !== this.metric ||
|
||||
this.breakdownWindow !== filters.window ||
|
||||
this.breakdownSpan !== filters.span ||
|
||||
this.breakdownScope !== filters.scope
|
||||
) {
|
||||
this.buckets = []
|
||||
}
|
||||
this.breakdownByFetchedFor = this.by
|
||||
this.breakdownMetricFetchedFor = this.metric
|
||||
this.breakdownWindow = filters.window
|
||||
this.breakdownSpan = filters.span
|
||||
this.breakdownScope = filters.scope
|
||||
const requestId = ++this.breakdownReqId
|
||||
this.loading = true
|
||||
try {
|
||||
if (filters.scope === 'all') {
|
||||
await this.fetchBreakdownFleetWide(filters.window, filters.span, requestId)
|
||||
@@ -244,7 +384,7 @@ export const useSpendStore = defineStore('spend', {
|
||||
await this.fetchBreakdownProviderScope(filters.scope.slice('provider:'.length), filters.window, filters.span, requestId)
|
||||
}
|
||||
if (requestId !== this.breakdownReqId) return
|
||||
this.error = ''
|
||||
this.breakdownError = ''
|
||||
if (filters.cmpSpec.requested) {
|
||||
await this.fetchComparison(filters.scope, filters.window, filters.span, filters.cmpSpec.offset, requestId)
|
||||
} else {
|
||||
@@ -254,9 +394,7 @@ export const useSpendStore = defineStore('spend', {
|
||||
} catch (err) {
|
||||
if (requestId !== this.breakdownReqId) return
|
||||
if (isAuthRejection(err)) return
|
||||
this.error = messageOf(err)
|
||||
} finally {
|
||||
if (requestId === this.breakdownReqId) this.loading = false
|
||||
this.breakdownError = messageOf(err)
|
||||
}
|
||||
},
|
||||
/**
|
||||
@@ -383,8 +521,17 @@ export const useSpendStore = defineStore('spend', {
|
||||
this.burndownBuckets = []
|
||||
this.burndownPoints = []
|
||||
this.burndownBudgetMicros = null
|
||||
// verify-ui-states-3.md NEW-2: also reset burndownError/Loading —
|
||||
// an in-flight burndown for a PREVIOUS scope has its own `finally`
|
||||
// fail the reqId check below (burndownReqId was just bumped
|
||||
// above), so without this its stale error/loading would otherwise
|
||||
// survive a detour through a provider scope (chart hidden) and
|
||||
// reappear once the reader switches back to a scope with a chart.
|
||||
this.burndownError = ''
|
||||
this.burndownLoading = false
|
||||
return
|
||||
}
|
||||
this.burndownLoading = true
|
||||
try {
|
||||
const span = currentMonthDaySpan(new Date())
|
||||
const res = await this.fetchSeries([scope], 'cost', 'day', span, 0)
|
||||
@@ -392,16 +539,30 @@ export const useSpendStore = defineStore('spend', {
|
||||
this.burndownBuckets = res.buckets
|
||||
this.burndownPoints = res.series.find((s) => s.scope === scope)?.points ?? new Array<number>(res.buckets.length).fill(0)
|
||||
this.burndownBudgetMicros = burndownBudgetMicros(scope)
|
||||
this.burndownError = ''
|
||||
} catch (err) {
|
||||
if (requestId !== this.burndownReqId) return
|
||||
if (isAuthRejection(err)) return
|
||||
this.error = messageOf(err)
|
||||
this.burndownError = messageOf(err)
|
||||
} finally {
|
||||
if (requestId === this.burndownReqId) this.burndownLoading = false
|
||||
}
|
||||
},
|
||||
async fetchDrilldown(): Promise<void> {
|
||||
const filters = useFiltersStore()
|
||||
// verify-ui-states-2.md #2: a genuine SELECTION change (window,
|
||||
// span, or the drill breadcrumb itself) resets drilldownLoaded so
|
||||
// the new selection's own skeleton shows — a background poll tick
|
||||
// (same three values) leaves it alone.
|
||||
if (this.drilldownWindow !== filters.window || this.drilldownSpan !== filters.span || this.drilldownDrillFetchedFor !== this.drill) {
|
||||
this.drilldownLoaded = false
|
||||
}
|
||||
this.drilldownWindow = filters.window
|
||||
this.drilldownSpan = filters.span
|
||||
this.drilldownDrillFetchedFor = this.drill
|
||||
const requestId = ++this.drilldownReqId
|
||||
const level = drilldownLevel(this.drill)
|
||||
this.drilldownLoading = true
|
||||
try {
|
||||
// usermodel only accepts day/month (redesign-plan.md section
|
||||
// 1.3.v) — clamped via lib/range.ts's shared dayOrMonthWindow so
|
||||
@@ -421,19 +582,28 @@ export const useSpendStore = defineStore('spend', {
|
||||
this.drilldownRows = res.rows
|
||||
this.drilldownMetrics = res.metrics
|
||||
this.drilldownDisabled = false
|
||||
this.drilldownError = ''
|
||||
this.drilldownLoaded = true
|
||||
} catch (err) {
|
||||
if (requestId !== this.drilldownReqId) return
|
||||
if (isAuthRejection(err)) return
|
||||
if (err instanceof AdminApiError && err.status === 404) {
|
||||
// usermodel with admin.stats.userModel off (redesign-plan.md
|
||||
// section 1.3.v) — a known, named-in-the-hint state, not an
|
||||
// error the reader needs to see in the status line.
|
||||
// error the reader needs to see in the status line. Still a
|
||||
// SETTLED fetch for this selection (AttributionDrilldown.vue's
|
||||
// own hint Alert takes precedence over skeleton/error/empty
|
||||
// regardless), so drilldownLoaded is set here too.
|
||||
this.drilldownRows = []
|
||||
this.drilldownMetrics = []
|
||||
this.drilldownDisabled = true
|
||||
this.drilldownError = ''
|
||||
this.drilldownLoaded = true
|
||||
return
|
||||
}
|
||||
this.error = messageOf(err)
|
||||
this.drilldownError = messageOf(err)
|
||||
} finally {
|
||||
if (requestId === this.drilldownReqId) this.drilldownLoading = false
|
||||
}
|
||||
},
|
||||
startPolling(): void {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { MAX_VISIBLE_TOASTS, TOAST_TIMEOUT_MS, useToastsStore } from './toasts'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useToastsStore.push', () => {
|
||||
it('enqueues a toast with the default timeout for its kind', () => {
|
||||
const toasts = useToastsStore()
|
||||
toasts.push({ kind: 'success', message: 'Copied' })
|
||||
expect(toasts.toasts).toHaveLength(1)
|
||||
expect(toasts.toasts[0]).toMatchObject({ kind: 'success', message: 'Copied', timeoutMs: TOAST_TIMEOUT_MS.success })
|
||||
})
|
||||
|
||||
it('error toasts default to the longer 6s timeout', () => {
|
||||
const toasts = useToastsStore()
|
||||
toasts.push({ kind: 'error', message: 'Export failed' })
|
||||
expect(toasts.toasts[0].timeoutMs).toBe(6000)
|
||||
expect(TOAST_TIMEOUT_MS.error).toBe(6000)
|
||||
})
|
||||
|
||||
it('success/info toasts default to the shorter 3s timeout', () => {
|
||||
const toasts = useToastsStore()
|
||||
toasts.push({ kind: 'success', message: 'a' })
|
||||
toasts.push({ kind: 'info', message: 'b' })
|
||||
expect(toasts.toasts[0].timeoutMs).toBe(3000)
|
||||
expect(toasts.toasts[1].timeoutMs).toBe(3000)
|
||||
})
|
||||
|
||||
it('accepts a per-push timeoutMs override', () => {
|
||||
const toasts = useToastsStore()
|
||||
toasts.push({ kind: 'success', message: 'a', timeoutMs: 100 })
|
||||
expect(toasts.toasts[0].timeoutMs).toBe(100)
|
||||
})
|
||||
|
||||
it('assigns each toast a distinct, increasing id', () => {
|
||||
const toasts = useToastsStore()
|
||||
toasts.push({ kind: 'success', message: 'a' })
|
||||
toasts.push({ kind: 'success', message: 'b' })
|
||||
expect(toasts.toasts[1].id).toBeGreaterThan(toasts.toasts[0].id)
|
||||
})
|
||||
|
||||
it('auto-dismisses after its own timeoutMs', () => {
|
||||
const toasts = useToastsStore()
|
||||
toasts.push({ kind: 'success', message: 'a', timeoutMs: 1000 })
|
||||
expect(toasts.toasts).toHaveLength(1)
|
||||
vi.advanceTimersByTime(999)
|
||||
expect(toasts.toasts).toHaveLength(1)
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(toasts.toasts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('dedupes an identical kind+message pushed consecutively', () => {
|
||||
const toasts = useToastsStore()
|
||||
toasts.push({ kind: 'error', message: 'refresh failed' })
|
||||
toasts.push({ kind: 'error', message: 'refresh failed' })
|
||||
expect(toasts.toasts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does NOT dedupe the same message once a different toast came in between', () => {
|
||||
const toasts = useToastsStore()
|
||||
toasts.push({ kind: 'error', message: 'refresh failed' })
|
||||
toasts.push({ kind: 'info', message: 'other' })
|
||||
toasts.push({ kind: 'error', message: 'refresh failed' })
|
||||
expect(toasts.toasts).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('does NOT dedupe the same message text across different kinds', () => {
|
||||
const toasts = useToastsStore()
|
||||
toasts.push({ kind: 'error', message: 'x' })
|
||||
toasts.push({ kind: 'success', message: 'x' })
|
||||
expect(toasts.toasts).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('caps visible toasts at MAX_VISIBLE_TOASTS, dropping the oldest first (FIFO)', () => {
|
||||
const toasts = useToastsStore()
|
||||
toasts.push({ kind: 'info', message: '1' })
|
||||
toasts.push({ kind: 'info', message: '2' })
|
||||
toasts.push({ kind: 'info', message: '3' })
|
||||
toasts.push({ kind: 'info', message: '4' })
|
||||
expect(MAX_VISIBLE_TOASTS).toBe(3)
|
||||
expect(toasts.toasts).toHaveLength(3)
|
||||
expect(toasts.toasts.map((t) => t.message)).toEqual(['2', '3', '4'])
|
||||
})
|
||||
|
||||
it('cancels a dropped toast\'s own timer so it cannot fire after eviction', () => {
|
||||
const toasts = useToastsStore()
|
||||
toasts.push({ kind: 'info', message: '1', timeoutMs: 500 })
|
||||
toasts.push({ kind: 'info', message: '2', timeoutMs: 500 })
|
||||
toasts.push({ kind: 'info', message: '3', timeoutMs: 500 })
|
||||
toasts.push({ kind: 'info', message: '4', timeoutMs: 500 }) // drops '1'
|
||||
expect(toasts.toasts.map((t) => t.message)).toEqual(['2', '3', '4'])
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(toasts.toasts).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useToastsStore.dismiss', () => {
|
||||
it('removes the named toast immediately', () => {
|
||||
const toasts = useToastsStore()
|
||||
toasts.push({ kind: 'success', message: 'a' })
|
||||
const id = toasts.toasts[0].id
|
||||
toasts.dismiss(id)
|
||||
expect(toasts.toasts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('cancels the pending auto-dismiss timer, so it does not double-fire on an id that no longer exists', () => {
|
||||
const toasts = useToastsStore()
|
||||
toasts.push({ kind: 'success', message: 'a', timeoutMs: 1000 })
|
||||
const id = toasts.toasts[0].id
|
||||
toasts.dismiss(id)
|
||||
// Nothing should throw when the (canceled) timer would otherwise have fired.
|
||||
expect(() => vi.advanceTimersByTime(2000)).not.toThrow()
|
||||
expect(toasts.toasts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is a no-op for an unknown id', () => {
|
||||
const toasts = useToastsStore()
|
||||
toasts.push({ kind: 'success', message: 'a' })
|
||||
toasts.dismiss(999)
|
||||
expect(toasts.toasts).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
/** ToastKind is one toast's visual/urgency tier — 'error' gets the longer default timeout (TOAST_TIMEOUT_MS) and an assertive aria-live region in ToastViewport.vue; 'success'/'info' share the shorter default and a polite region. */
|
||||
export type ToastKind = 'success' | 'error' | 'info'
|
||||
|
||||
/** One toast entry ToastViewport.vue renders — `id` is this store's own monotonic counter, never reused, so a dismiss/timer always targets the exact entry it was scheduled for even after other toasts have come and gone. */
|
||||
export interface Toast {
|
||||
id: number
|
||||
kind: ToastKind
|
||||
message: string
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
/** TOAST_TIMEOUT_MS is each kind's default auto-dismiss delay (states-plan.md item 3: "auto-dismiss default 3s success/info, 6s error") — a caller may override per-push via the optional `timeoutMs` argument. */
|
||||
export const TOAST_TIMEOUT_MS: Record<ToastKind, number> = {
|
||||
success: 3000,
|
||||
info: 3000,
|
||||
error: 6000,
|
||||
}
|
||||
|
||||
/** MAX_VISIBLE_TOASTS caps how many toasts ToastViewport.vue shows at once (states-plan.md item 3: "max 3 visible") — pushing a 4th silently drops the OLDEST (FIFO), matching a notification stack's usual behavior: the newest, most relevant toast always stays. */
|
||||
export const MAX_VISIBLE_TOASTS = 3
|
||||
|
||||
export interface PushToastInput {
|
||||
kind: ToastKind
|
||||
message: string
|
||||
/** Overrides TOAST_TIMEOUT_MS[kind] for this one toast. */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* useToastsStore is the panel's one in-house toast queue (states-plan.md
|
||||
* item 3) — copy-to-clipboard, CSV export, snippet copy, API key
|
||||
* generation, and background-refresh-failure callers all push through
|
||||
* this single store; ToastViewport.vue (mounted once in App.vue) is the
|
||||
* only reader. Kept deliberately tiny — no external toast library, this
|
||||
* panel's whole toast surface is "a short queue with auto-dismiss
|
||||
* timers" — rather than pulling in a dependency for it.
|
||||
*/
|
||||
export const useToastsStore = defineStore('toasts', {
|
||||
state: () => ({
|
||||
toasts: [] as Toast[],
|
||||
nextId: 1,
|
||||
/** timers is keyed by toast id, not held on the Toast object itself — a setTimeout handle is not serializable/comparable state a component template should ever touch. */
|
||||
timers: {} as Record<number, ReturnType<typeof setTimeout>>,
|
||||
}),
|
||||
actions: {
|
||||
/**
|
||||
* push enqueues one toast, deduping it against the MOST RECENTLY
|
||||
* pushed toast only (states-plan.md item 3: "dedupe identical
|
||||
* consecutive messages") — an identical kind+message pushed again
|
||||
* later, after other toasts have appeared in between, is NOT a
|
||||
* duplicate of the earlier one and is shown again (e.g. the same
|
||||
* background-refresh-failure message recurring after a brief
|
||||
* recovery is worth re-surfacing, not silently swallowed forever).
|
||||
*/
|
||||
push({ kind, message, timeoutMs }: PushToastInput): void {
|
||||
const mostRecent = this.toasts[this.toasts.length - 1]
|
||||
if (mostRecent && mostRecent.kind === kind && mostRecent.message === message) return
|
||||
|
||||
const id = this.nextId++
|
||||
const toast: Toast = { id, kind, message, timeoutMs: timeoutMs ?? TOAST_TIMEOUT_MS[kind] }
|
||||
this.toasts.push(toast)
|
||||
|
||||
if (this.toasts.length > MAX_VISIBLE_TOASTS) {
|
||||
const dropped = this.toasts.shift()
|
||||
if (dropped) this.clearTimer(dropped.id)
|
||||
}
|
||||
|
||||
this.timers[id] = setTimeout(() => this.dismiss(id), toast.timeoutMs)
|
||||
},
|
||||
/** dismiss removes one toast by id (the Retry/close button's own click handler, or a timer firing) and clears its pending auto-dismiss timer, if any. */
|
||||
dismiss(id: number): void {
|
||||
this.clearTimer(id)
|
||||
this.toasts = this.toasts.filter((t) => t.id !== id)
|
||||
},
|
||||
/** clearTimer cancels and forgets one toast's pending setTimeout — shared by dismiss() and push()'s own over-capacity eviction, so a dropped or dismissed toast never fires its timer against an id no longer in `toasts`. */
|
||||
clearTimer(id: number): void {
|
||||
const timer = this.timers[id]
|
||||
if (timer === undefined) return
|
||||
clearTimeout(timer)
|
||||
delete this.timers[id]
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user