mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
refactor(webui): extract repeated dashboard elements into shared components
Markup and helpers that were copied between pages now live in one place: - ChartCard, StatDisabledAlert, StatItem, DetailField, BudgetRatioBars, LabeledSelect, TargetPicker and LoadStateView components - lib/scope.ts, lib/columns.ts and lib/chart-palette.ts - messageOf, isAuthRejection, and the USD to micro-USD conversion helpers - one monthly cost projection, one date formatter, and the useCopyFeedback composable Behaviour is unchanged. Before and after screenshots of every page match at 390 and 1440 px. Empty-state text now comes from the shared EmptyState component, and the user run-out date uses the short date format. admin_assets_gen.go is regenerated. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
+82
-64
File diff suppressed because one or more lines are too long
@@ -11,13 +11,12 @@ 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 StatDisabledAlert from '@/components/StatDisabledAlert.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 { drilldownLevel, makeScope } from '@/lib/scope'
|
||||
import { SPEND_METRIC_LABEL } from '@/stores/spend'
|
||||
import type { AdminTotalsResponse, HistoryMetric, HistoryWindow } from '@/types/api'
|
||||
|
||||
@@ -70,15 +69,7 @@ const props = defineProps<{
|
||||
|
||||
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>(() => {
|
||||
if (props.drill === '') return { kind: 'group' }
|
||||
if (props.drill.startsWith('group:')) return { kind: 'user', group: props.drill.slice('group:'.length) }
|
||||
return { kind: 'usermodel', user: props.drill.slice('user:'.length) }
|
||||
})
|
||||
const level = computed(() => drilldownLevel(props.drill))
|
||||
|
||||
/**
|
||||
* parentGroupLabel is a BEST-EFFORT breadcrumb label for the group a
|
||||
@@ -97,15 +88,15 @@ function goToGroups(): void {
|
||||
emit('update:drill', '')
|
||||
}
|
||||
function goToParentGroup(): void {
|
||||
if (parentGroupLabel.value) emit('update:drill', `group:${parentGroupLabel.value}`)
|
||||
if (parentGroupLabel.value) emit('update:drill', makeScope('group', parentGroupLabel.value))
|
||||
}
|
||||
function onRowClick(id: string): void {
|
||||
const l = level.value
|
||||
if (l.kind === 'group') {
|
||||
emit('update:drill', `group:${id}`)
|
||||
emit('update:drill', makeScope('group', id))
|
||||
} else if (l.kind === 'user' && props.userModelStatsEnabled) {
|
||||
parentGroupLabel.value = l.group
|
||||
emit('update:drill', `user:${id}`)
|
||||
emit('update:drill', makeScope('user', id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,13 +180,12 @@ const noTrafficInRange = computed<boolean>(() => !showUserModelHint.value && pro
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<ClampedRangeNotice v-if="level.kind === 'usermodel'" :window="window" :span="span" />
|
||||
<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>
|
||||
<StatDisabledAlert
|
||||
v-if="showUserModelHint"
|
||||
title="Per-user-model statistics are off"
|
||||
config-key="admin.stats.userModel"
|
||||
purpose="to see per-model attribution for this user."
|
||||
/>
|
||||
<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.">
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import UsageBar from '@/components/UsageBar.vue'
|
||||
import { BUDGET_RATIO_LABEL, budgetValueText } from '@/lib/usage-bars'
|
||||
import type { BudgetRatio } from '@/lib/usage-bars'
|
||||
|
||||
/**
|
||||
* BudgetRatioBars (reuse-audit.md F7) is the ONE budget-ratio bar row —
|
||||
* one labelled UsageBar per configured limit — UserDetail.vue's Headroom
|
||||
* card and ConsumerDirectory.vue's group accordion both hand-rolled: a
|
||||
* label (BUDGET_RATIO_LABEL) beside each lib/usage-bars.ts budgetRatios()
|
||||
* entry, its UsageBar sized to that ratio, and its "used / limit" text
|
||||
* (the shared budgetValueText).
|
||||
*/
|
||||
defineProps<{
|
||||
ratios: BudgetRatio[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||
<span v-for="ratio in ratios" :key="ratio.id" class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{{ BUDGET_RATIO_LABEL[ratio.id] }}
|
||||
<UsageBar :ratio="ratio.ratio" :label="BUDGET_RATIO_LABEL[ratio.id]" :value-text="budgetValueText(ratio)" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import SkeletonChart from '@/components/SkeletonChart.vue'
|
||||
import ChartCard from '@/components/ChartCard.vue'
|
||||
import StatItem from '@/components/StatItem.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 { NOT_ENOUGH_DATA } from '@/lib/forecast'
|
||||
import { formatBucketLabel, formatCost, formatShortDate } from '@/lib/format'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
|
||||
/**
|
||||
@@ -64,33 +64,25 @@ const willExceed = computed<boolean>(() => props.budgetMicros !== null && spentM
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Burn-down</CardTitle>
|
||||
<CardDescription>Cumulative spend this UTC calendar month against the configured budget.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-4">
|
||||
<SkeletonChart v-if="state === 'skeleton'" />
|
||||
<ErrorState v-else-if="state === 'error'" :message="error ?? ''" :on-retry="onRetry" />
|
||||
<template v-else>
|
||||
<ChartCard
|
||||
title="Burn-down"
|
||||
description="Cumulative spend this UTC calendar month against the configured budget."
|
||||
:state="state"
|
||||
:error="error ?? ''"
|
||||
:on-retry="onRetry"
|
||||
>
|
||||
<div 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>
|
||||
</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>
|
||||
<StatItem label="Spent so far" :tone="willExceed ? 'destructive' : 'default'">{{ formatCost(spentMicros) }}</StatItem>
|
||||
<StatItem v-if="budgetMicros !== null" label="Budget">{{ formatCost(budgetMicros) }}</StatItem>
|
||||
<StatItem
|
||||
v-if="budgetMicros !== null"
|
||||
label="Projected run-out"
|
||||
:tone="willExceed ? 'destructive' : 'default'"
|
||||
:hint="runOut === null ? NOT_ENOUGH_DATA : undefined"
|
||||
>
|
||||
{{ runOut === null ? '' : formatShortDate(runOut) }}
|
||||
</StatItem>
|
||||
</div>
|
||||
<p v-if="budgetMicros === null" class="text-sm text-muted-foreground">No budget configured for this scope.</p>
|
||||
<TimeSeriesChart
|
||||
@@ -100,7 +92,6 @@ const willExceed = computed<boolean>(() => props.budgetMicros !== null && spentM
|
||||
:value-formatter="formatCost"
|
||||
ariaLabel="Cumulative spend this month against the configured budget"
|
||||
/>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ChartCard>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import SkeletonChart from '@/components/SkeletonChart.vue'
|
||||
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
|
||||
import type { TimeSeriesComparisonDataset, TimeSeriesDataset } from '@/components/TimeSeriesChart.vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatExactInt } from '@/lib/format'
|
||||
import type { LoadState } from '@/lib/load-state'
|
||||
|
||||
/**
|
||||
* ChartCard (reuse-audit.md F1) is the ONE `Card > CardHeader(Title,
|
||||
* Description?) > CardContent > skeleton|error|(empty)|chart` shell every
|
||||
* time-series chart card in this panel used to hand-roll — ReliabilityPage
|
||||
* (5 real chart cards + 4 skeleton-only loading cards), UserDetail
|
||||
* (requests/cost), SpendPage (breakdown), BurnDownChart. `state` drives the
|
||||
* skeleton/error/ready branch (lib/load-state.ts's LoadState — the SAME
|
||||
* precedence every other card in this panel already follows); `error` and
|
||||
* `onRetry` forward straight to ErrorState. The default slot overrides the
|
||||
* ready-state body for a chart that needs more than a bare TimeSeriesChart
|
||||
* (BurnDownChart's own stat row above its chart) — omitted, this renders
|
||||
* TimeSeriesChart directly from `labels`/`datasets`/etc. `#header-extra`
|
||||
* adds header content beyond title/description (e.g. a per-card control)
|
||||
* without needing the caller to rebuild CardHeader itself.
|
||||
*
|
||||
* `showEmpty` (default false, opt-in) is the ONLY thing that turns
|
||||
* `state === 'empty'` into a distinct EmptyState render instead of falling
|
||||
* through to the ready branch (the default slot, or the bare
|
||||
* TimeSeriesChart). None of today's call sites had an "empty" concept
|
||||
* before this component existed — they always rendered their chart (or,
|
||||
* for BurnDownChart, its stat row above the chart) for any non-skeleton,
|
||||
* non-error state, even with zero data points. Defaulting `showEmpty` to
|
||||
* false keeps that exact behaviour; a future card that genuinely wants
|
||||
* "No data for this range." text can opt in per-instance.
|
||||
*
|
||||
* `bare` (true) skips the Card/CardHeader/CardTitle chrome entirely,
|
||||
* rendering only the state-branch content — SpendPage's breakdown chart
|
||||
* has its own bespoke header (Tabs, a metric Select, a running total) and,
|
||||
* today, no Card wrapper at all around the chart; forcing one on would be
|
||||
* a visible layout change this refactor must not make (behaviour-
|
||||
* preserving rule), so `bare` lets it reuse the skeleton/error/ready
|
||||
* branch without the surrounding card border/padding.
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
description?: string
|
||||
state: LoadState
|
||||
error?: string
|
||||
onRetry?: () => void | Promise<void>
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
/** Opt-in: renders EmptyState instead of the ready branch when `state === 'empty'` — see this component's own doc comment. Defaults to false (behaviour-preserving: 'empty' falls through to ready everywhere until a caller explicitly asks for the distinct empty render). */
|
||||
showEmpty?: boolean
|
||||
bare?: boolean
|
||||
/** Omit when this ChartCard is only ever used in a non-'ready' state (e.g. one of the 4 skeleton-only loading cards below) — never read outside the 'ready' branch. */
|
||||
labels?: string[]
|
||||
datasets?: TimeSeriesDataset[]
|
||||
comparisonDatasets?: TimeSeriesComparisonDataset[]
|
||||
stacked?: boolean
|
||||
valueFormatter?: (value: number) => string
|
||||
/** Required whenever `state` can reach 'ready' with the default TimeSeriesChart body (TimeSeriesChart's own ariaLabel doc comment: no safe generic default) — optional here only so a skeleton-only ChartCard is not forced to pass a meaningless placeholder. */
|
||||
ariaLabel?: string
|
||||
}>(),
|
||||
{
|
||||
description: undefined,
|
||||
error: '',
|
||||
onRetry: undefined,
|
||||
emptyTitle: 'No data for this range.',
|
||||
emptyDescription: undefined,
|
||||
showEmpty: false,
|
||||
bare: false,
|
||||
labels: () => [],
|
||||
datasets: () => [],
|
||||
comparisonDatasets: () => [],
|
||||
stacked: false,
|
||||
valueFormatter: formatExactInt,
|
||||
ariaLabel: '',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="bare">
|
||||
<SkeletonChart v-if="state === 'skeleton'" />
|
||||
<ErrorState v-else-if="state === 'error'" :message="error" :on-retry="onRetry" />
|
||||
<EmptyState v-else-if="state === 'empty' && showEmpty" :title="emptyTitle" :description="emptyDescription" />
|
||||
<slot v-else>
|
||||
<TimeSeriesChart
|
||||
:labels="labels"
|
||||
:datasets="datasets"
|
||||
:comparison-datasets="comparisonDatasets"
|
||||
:stacked="stacked"
|
||||
:value-formatter="valueFormatter"
|
||||
:ariaLabel="ariaLabel"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
<Card v-else>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ title }}</CardTitle>
|
||||
<CardDescription v-if="description">{{ description }}</CardDescription>
|
||||
<slot name="header-extra" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SkeletonChart v-if="state === 'skeleton'" />
|
||||
<ErrorState v-else-if="state === 'error'" :message="error" :on-retry="onRetry" />
|
||||
<EmptyState v-else-if="state === 'empty' && showEmpty" :title="emptyTitle" :description="emptyDescription" />
|
||||
<slot v-else>
|
||||
<TimeSeriesChart
|
||||
:labels="labels"
|
||||
:datasets="datasets"
|
||||
:comparison-datasets="comparisonDatasets"
|
||||
:stacked="stacked"
|
||||
:value-formatter="valueFormatter"
|
||||
:ariaLabel="ariaLabel"
|
||||
/>
|
||||
</slot>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -3,30 +3,32 @@ import type { SortingState } from '@tanstack/vue-table'
|
||||
import { getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import BudgetRatioBars from '@/components/BudgetRatioBars.vue'
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import CostForecastCard from '@/components/CostForecastCard.vue'
|
||||
import CsvExportButton from '@/components/CsvExportButton.vue'
|
||||
import DetailField from '@/components/DetailField.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import LoadStateView from '@/components/LoadStateView.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 StatItem from '@/components/StatItem.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'
|
||||
import UsageTable from '@/components/UsageTable.vue'
|
||||
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 { monthCostProjection, NOT_ENOUGH_DATA, WILL_EXCEED_TITLE } from '@/lib/forecast'
|
||||
import { formatCost, 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 { budgetRatios } from '@/lib/usage-bars'
|
||||
import { usageColumns } from '@/lib/usage-columns'
|
||||
import { usageCsv } from '@/lib/usage-csv'
|
||||
import { groupMatches, groupNameMatches, matchingMembersOfGroup, membersOfGroup, userMatches } from '@/lib/usage-search'
|
||||
@@ -227,19 +229,30 @@ function isGlobPattern(entry: string): boolean {
|
||||
return /[*?[]/.test(entry)
|
||||
}
|
||||
|
||||
/** budgetValueText renders one BudgetRatio's "used / limit" detail text (UsageBar's aria-valuetext/title). */
|
||||
function budgetValueText(ratio: BudgetRatio): string {
|
||||
if (ratio.id.startsWith('cost')) return `${formatCost(ratio.used)} / ${formatCost(ratio.limit)}`
|
||||
return `${formatExactInt(ratio.used)} / ${formatExactInt(ratio.limit)}`
|
||||
}
|
||||
|
||||
/** groupCostProjection mirrors usage-columns.ts's own costMonthProjColumn math for one group row. */
|
||||
/** groupCostProjection renders lib/forecast.ts's shared monthCostProjection (reuse-audit.md F7) for one group row as display text. */
|
||||
function groupCostProjection(entry: AdminUsageEntryView, now: Date): { text: string; willExceed: boolean } {
|
||||
const elapsedFraction = monthProgress(now)
|
||||
const projected = projectMonthEnd(entry.costPerMonthMicroUsd, elapsedFraction)
|
||||
if (projected === null) return { text: 'not enough data yet', willExceed: false }
|
||||
return { text: formatCost(projected), willExceed: headroom(projected, entry.limits?.costPerMonthUSD).willExceed }
|
||||
const projection = monthCostProjection(entry, now)
|
||||
return { text: projection.micros === null ? NOT_ENOUGH_DATA : formatCost(projection.micros), willExceed: projection.willExceed }
|
||||
}
|
||||
/**
|
||||
* GROUP_DETAIL_FIELDS (reuse-audit.md F6) drives the group accordion's 4
|
||||
* uniform "storeDown ? '?' : <CompactNumber>" detail fields with one
|
||||
* v-for instead of four hand-rolled DetailField copies — the same label/
|
||||
* accessor pairs lib/usage-columns.ts's own numericColumn calls already
|
||||
* name for these exact ids (tokInDay, tokOutDay, tokInMonth, tokOutMonth).
|
||||
* req/min and rejected/day are rendered explicitly, ahead of this v-for,
|
||||
* to keep the HEAD field order (rejected/day also needs its own
|
||||
* conditional destructive-tone class). Not the ColumnDef objects
|
||||
* themselves: a TanStack ColumnDef's `cell` needs a full CellContext
|
||||
* (row/column/table) to render, which this plain `dl` grid — driven by
|
||||
* `row.original` alone, no table instance — has no reason to construct.
|
||||
*/
|
||||
const GROUP_DETAIL_FIELDS: { label: string; read: (entry: AdminUsageEntryView) => number }[] = [
|
||||
{ label: 'tokIn/day', read: (e) => e.tokensInPerDay },
|
||||
{ label: 'tokOut/day', read: (e) => e.tokensOutPerDay },
|
||||
{ label: 'tokIn/month', read: (e) => e.tokensInPerMonth },
|
||||
{ label: 'tokOut/month', read: (e) => e.tokensOutPerMonth },
|
||||
]
|
||||
|
||||
// --- CSV export ---
|
||||
const usersColumns = computed(() => usageColumns('Name', 'Group', groupNameOf, now.value, sourceOf))
|
||||
@@ -289,28 +302,13 @@ function groupsCsv(): string {
|
||||
<CardDescription>Usage summed across every user and group combined.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-5">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Requests/day</p>
|
||||
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="usage.total.requestsPerDay" /></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Rejected/day</p>
|
||||
<p class="text-lg font-semibold tabular-nums" :class="usage.total.rejectionsPerDay > 0 ? 'text-destructive' : undefined">
|
||||
<StatItem label="Requests/day"><CompactNumber :value="usage.total.requestsPerDay" /></StatItem>
|
||||
<StatItem label="Rejected/day" :tone="usage.total.rejectionsPerDay > 0 ? 'destructive' : 'default'">
|
||||
<CompactNumber :value="usage.total.rejectionsPerDay" />
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Tokens in/day</p>
|
||||
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="usage.total.tokensInPerDay" /></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Tokens out/day</p>
|
||||
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="usage.total.tokensOutPerDay" /></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Cost/day</p>
|
||||
<p class="text-lg font-semibold tabular-nums">{{ formatCost(usage.total.costPerDayMicroUsd) }}</p>
|
||||
</div>
|
||||
</StatItem>
|
||||
<StatItem label="Tokens in/day"><CompactNumber :value="usage.total.tokensInPerDay" /></StatItem>
|
||||
<StatItem label="Tokens out/day"><CompactNumber :value="usage.total.tokensOutPerDay" /></StatItem>
|
||||
<StatItem label="Cost/day">{{ formatCost(usage.total.costPerDayMicroUsd) }}</StatItem>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -354,9 +352,7 @@ function groupsCsv(): string {
|
||||
</div>
|
||||
<AccordionContent>
|
||||
<dl class="mb-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm sm:grid-cols-4">
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground" :title="GLOB_HINT">Providers</dt>
|
||||
<dd>
|
||||
<DetailField label="Providers" :label-title="GLOB_HINT">
|
||||
<div v-if="row.original.providers?.length" class="flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
v-for="providerName in row.original.providers"
|
||||
@@ -370,11 +366,8 @@ function groupsCsv(): string {
|
||||
</Badge>
|
||||
</div>
|
||||
<span v-else class="text-muted-foreground">All providers</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground" :title="GLOB_HINT">Models</dt>
|
||||
<dd>
|
||||
</DetailField>
|
||||
<DetailField label="Models" :label-title="GLOB_HINT">
|
||||
<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" />
|
||||
@@ -382,11 +375,8 @@ function groupsCsv(): string {
|
||||
</template>
|
||||
</div>
|
||||
<span v-else class="text-muted-foreground">All models</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground" :title="GLOB_HINT">MCP servers</dt>
|
||||
<dd>
|
||||
</DetailField>
|
||||
<DetailField label="MCP servers" :label-title="GLOB_HINT">
|
||||
<div v-if="row.original.mcpServers?.length" class="flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
v-for="serverName in row.original.mcpServers"
|
||||
@@ -400,11 +390,8 @@ function groupsCsv(): string {
|
||||
</Badge>
|
||||
</div>
|
||||
<span v-else class="text-muted-foreground">All MCP servers</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground" :title="GLOB_HINT">Agents</dt>
|
||||
<dd>
|
||||
</DetailField>
|
||||
<DetailField label="Agents" :label-title="GLOB_HINT">
|
||||
<div v-if="row.original.agents?.length" class="flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
v-for="agentName in row.original.agents"
|
||||
@@ -418,81 +405,37 @@ function groupsCsv(): string {
|
||||
</Badge>
|
||||
</div>
|
||||
<span v-else class="text-muted-foreground">All agents</span>
|
||||
</dd>
|
||||
</div>
|
||||
</DetailField>
|
||||
</dl>
|
||||
<dl class="mb-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm sm:grid-cols-4">
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">Limits</dt>
|
||||
<dd>{{ formatLimits(row.original.limits) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">req/min</dt>
|
||||
<dd class="tabular-nums">
|
||||
<DetailField label="Limits">{{ formatLimits(row.original.limits) }}</DetailField>
|
||||
<DetailField label="req/min" numeric>
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.requestsPerMinute" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">rejected/day</dt>
|
||||
<dd class="tabular-nums" :class="!row.original.storeDown && row.original.rejectionsPerDay > 0 ? 'text-destructive' : undefined">
|
||||
</DetailField>
|
||||
<DetailField
|
||||
label="rejected/day"
|
||||
numeric
|
||||
:class="!row.original.storeDown && row.original.rejectionsPerDay > 0 ? 'text-destructive' : undefined"
|
||||
>
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.rejectionsPerDay" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">tokIn/day</dt>
|
||||
<dd class="tabular-nums">
|
||||
</DetailField>
|
||||
<DetailField v-for="field in GROUP_DETAIL_FIELDS" :key="field.label" :label="field.label" numeric>
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.tokensInPerDay" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">tokOut/day</dt>
|
||||
<dd class="tabular-nums">
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.tokensOutPerDay" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">tokIn/month</dt>
|
||||
<dd class="tabular-nums">
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.tokensInPerMonth" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">tokOut/month</dt>
|
||||
<dd class="tabular-nums">
|
||||
<template v-if="row.original.storeDown">?</template>
|
||||
<CompactNumber v-else :value="row.original.tokensOutPerMonth" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">cost/month</dt>
|
||||
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : formatCost(row.original.costPerMonthMicroUsd) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">cost/month (proj.)</dt>
|
||||
<dd
|
||||
class="tabular-nums"
|
||||
<CompactNumber v-else :value="field.read(row.original)" />
|
||||
</DetailField>
|
||||
<DetailField label="cost/month" numeric>{{ row.original.storeDown ? '?' : formatCost(row.original.costPerMonthMicroUsd) }}</DetailField>
|
||||
<DetailField
|
||||
label="cost/month (proj.)"
|
||||
numeric
|
||||
:class="!row.original.storeDown && groupCostProjection(row.original, now).willExceed ? 'text-destructive' : undefined"
|
||||
:title="!row.original.storeDown && groupCostProjection(row.original, now).willExceed ? 'Projected to exceed the configured cost/month limit' : undefined"
|
||||
:title="!row.original.storeDown && groupCostProjection(row.original, now).willExceed ? WILL_EXCEED_TITLE : undefined"
|
||||
>
|
||||
{{ row.original.storeDown ? '?' : groupCostProjection(row.original, now).text }}
|
||||
</dd>
|
||||
</div>
|
||||
</DetailField>
|
||||
</dl>
|
||||
<div v-if="budgetRatios(row.original).length" class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||
<span
|
||||
v-for="ratio in budgetRatios(row.original)"
|
||||
:key="ratio.id"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ BUDGET_RATIO_LABEL[ratio.id] }}
|
||||
<UsageBar :ratio="ratio.ratio" :label="BUDGET_RATIO_LABEL[ratio.id]" :value-text="budgetValueText(ratio)" />
|
||||
</span>
|
||||
</div>
|
||||
<BudgetRatioBars v-if="budgetRatios(row.original).length" class="mb-3" :ratios="budgetRatios(row.original)" />
|
||||
<p class="mb-1.5 text-xs font-medium text-muted-foreground">Members</p>
|
||||
<UsageTable
|
||||
id-label="Name"
|
||||
@@ -508,7 +451,7 @@ function groupsCsv(): string {
|
||||
<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>
|
||||
<EmptyState v-else :title="groupsEmptyMessage" />
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -524,10 +467,8 @@ 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" />
|
||||
<LoadStateView :state="dashboardLoadState" :error="dashboard.error" :on-retry="dashboard.refresh" skeleton="table" :rows="5" :cols="5">
|
||||
<UsageTable
|
||||
v-else
|
||||
id-label="Name"
|
||||
secondary-column-label="Group"
|
||||
:entries="filteredUsers"
|
||||
@@ -537,6 +478,7 @@ function groupsCsv(): string {
|
||||
:source-value="sourceOf"
|
||||
v-model:sorting="usersSorting"
|
||||
/>
|
||||
</LoadStateView>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import LabeledSelect from '@/components/LabeledSelect.vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { costAvoidedMicros, freeRows, pickReferenceModel } from '@/lib/cost-avoided'
|
||||
import { formatCost } from '@/lib/format'
|
||||
import type { AdminCatalogModel, AdminUsageModelEntry } from '@/types/api'
|
||||
@@ -81,6 +81,8 @@ const priceableOptions = computed<{ id: string; requests: number }[]>(() => {
|
||||
return out.sort((a, b) => b.requests - a.requests)
|
||||
})
|
||||
|
||||
const referenceOptions = computed(() => priceableOptions.value.map((opt) => ({ value: opt.id, label: opt.id })))
|
||||
|
||||
const defaultReference = computed<string | null>(() => pickReferenceModel(props.models, props.catalog))
|
||||
const effectiveReference = computed<string | null>(() => props.refModel || defaultReference.value)
|
||||
const referenceCatalog = computed<AdminCatalogModel | undefined>(() =>
|
||||
@@ -111,17 +113,15 @@ function onReferenceUpdate(value: unknown): void {
|
||||
<CardTitle>Cost avoided</CardTitle>
|
||||
<CardDescription>What free-tier traffic in range would have cost at a real model's own rates.</CardDescription>
|
||||
</div>
|
||||
<div v-if="priceableOptions.length > 0" class="flex flex-col gap-1">
|
||||
<label id="cost-avoided-ref-label" class="text-xs font-medium text-muted-foreground">Reference model</label>
|
||||
<Select :model-value="effectiveReference ?? undefined" @update:model-value="onReferenceUpdate">
|
||||
<SelectTrigger aria-labelledby="cost-avoided-ref-label" class="w-56">
|
||||
<SelectValue placeholder="Pick a reference model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="opt in priceableOptions" :key="opt.id" :value="opt.id">{{ opt.id }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<LabeledSelect
|
||||
v-if="priceableOptions.length > 0"
|
||||
label="Reference model"
|
||||
:model-value="effectiveReference ?? ''"
|
||||
:options="referenceOptions"
|
||||
placeholder="Pick a reference model"
|
||||
trigger-class="w-56"
|
||||
@update:model-value="onReferenceUpdate"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import StatItem from '@/components/StatItem.vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
|
||||
import { headroom, monthProgress, NOT_ENOUGH_DATA, projectMonthEnd, WILL_EXCEED_TITLE } from '@/lib/forecast'
|
||||
import { formatCost } from '@/lib/format'
|
||||
import type { LimitsConfig } from '@/types/api'
|
||||
|
||||
@@ -53,26 +54,16 @@ const forecast = computed(() => headroom(projectedMicros.value, props.limits?.co
|
||||
<CardDescription>Projected month-end spend, extrapolated from month-to-date usage (UTC calendar month).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Month to date</p>
|
||||
<p class="text-lg font-semibold tabular-nums">{{ formatCost(mtdMicros) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Projected month-end</p>
|
||||
<p v-if="projectedMicros === null" class="text-sm text-muted-foreground">not enough data yet</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-lg font-semibold tabular-nums"
|
||||
:class="forecast.willExceed ? 'text-destructive' : undefined"
|
||||
:title="forecast.willExceed ? 'Projected to exceed the configured cost/month limit' : undefined"
|
||||
<StatItem label="Month to date">{{ formatCost(mtdMicros) }}</StatItem>
|
||||
<StatItem
|
||||
label="Projected month-end"
|
||||
:tone="forecast.willExceed ? 'destructive' : 'default'"
|
||||
:title="forecast.willExceed ? WILL_EXCEED_TITLE : undefined"
|
||||
:hint="projectedMicros === null ? NOT_ENOUGH_DATA : undefined"
|
||||
>
|
||||
{{ formatCost(projectedMicros) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="forecast.limitMicros !== null">
|
||||
<p class="text-muted-foreground">Limit</p>
|
||||
<p class="text-lg font-semibold tabular-nums">{{ formatCost(forecast.limitMicros) }}</p>
|
||||
</div>
|
||||
{{ projectedMicros === null ? '' : formatCost(projectedMicros) }}
|
||||
</StatItem>
|
||||
<StatItem v-if="forecast.limitMicros !== null" label="Limit">{{ formatCost(forecast.limitMicros) }}</StatItem>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* DetailField (reuse-audit.md F6) is the ONE `dt`/`dd` detail-field pair
|
||||
* every accordion/expanded-row detail grid in this panel used to hand-
|
||||
* roll: a small muted `dt` label (`labelTitle` for a hint on the LABEL
|
||||
* itself, e.g. the glob-pattern hint on a group's Providers/Models/MCP
|
||||
* servers/Agents fields) and a `dd` for the value (default slot) —
|
||||
* `numeric` adds `tabular-nums` to the `dd` for a figure that should align
|
||||
* in a monospaced-digit grid. `inheritAttrs: false` + `v-bind="$attrs"` on
|
||||
* the `dd` (not the root `div`) forwards any other attribute a caller
|
||||
* passes straight onto the VALUE element — a conditional `class` (e.g. a
|
||||
* destructive rejected-count) or `title` (e.g. a "projected to exceed"
|
||||
* tooltip) lands on the same element it did before this extraction, never
|
||||
* on the label.
|
||||
*/
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
labelTitle?: string
|
||||
numeric?: boolean
|
||||
}>(),
|
||||
{ labelTitle: undefined, numeric: false },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground" :title="labelTitle">{{ label }}</dt>
|
||||
<dd v-bind="$attrs" :class="numeric ? 'tabular-nums' : undefined">
|
||||
<slot />
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,9 +2,9 @@
|
||||
import { faArrowRightArrowLeft } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import LabeledSelect from '@/components/LabeledSelect.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { SCOPE_PATTERN } from '@/lib/hash-state'
|
||||
import { RANGE_KEYS } from '@/lib/range'
|
||||
import type { RangeKey } from '@/lib/range'
|
||||
@@ -21,6 +21,7 @@ const RANGE_LABEL: Record<RangeKey, string> = {
|
||||
'6mo': 'Last 6 months',
|
||||
'12mo': 'Last 12 months',
|
||||
}
|
||||
const RANGE_OPTIONS = RANGE_KEYS.map((key) => ({ value: key, label: RANGE_LABEL[key] }))
|
||||
|
||||
/**
|
||||
* GlobalFilterBar (redesign-plan.md section 3.1) is the shell's one
|
||||
@@ -94,17 +95,7 @@ function commitScope(): void {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label id="global-filter-range-label" class="text-xs font-medium text-muted-foreground">Range</label>
|
||||
<Select :model-value="filters.range" @update:model-value="onRangeUpdate">
|
||||
<SelectTrigger aria-labelledby="global-filter-range-label" class="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="key in RANGE_KEYS" :key="key" :value="key">{{ RANGE_LABEL[key] }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<LabeledSelect label="Range" :model-value="filters.range" :options="RANGE_OPTIONS" trigger-class="w-36" @update:model-value="onRangeUpdate" />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
|
||||
export interface LabeledSelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* LabeledSelect (reuse-audit.md F11) is the ONE "label above a Select,
|
||||
* wired via aria-labelledby" block this panel used to hand-roll at every
|
||||
* filter/target picker (GlobalFilterBar's Range, CostAvoidedCard's
|
||||
* reference model, SpendPage's Metric, ReliabilityPage's Provider,
|
||||
* UserForm's Primary group). `allLabel`, when set, injects an "All X"
|
||||
* option and maps it to/from `modelValue === ''` internally — Radix/
|
||||
* reka-ui Select items cannot hold an empty-string value themselves
|
||||
* (EventsView.vue's/ReliabilityPage.vue's own identical ALL_KINDS/
|
||||
* ALL_PROVIDERS_OPTION doc comments), so every "All" filter used to
|
||||
* re-implement this same sentinel swap at its own template boundary.
|
||||
* Omit `allLabel` for a plain required-selection picker with no "All"
|
||||
* concept (UserForm's Primary group, CostAvoidedCard's reference model).
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
modelValue: string
|
||||
options: LabeledSelectOption[]
|
||||
allLabel?: string
|
||||
placeholder?: string
|
||||
triggerClass?: string
|
||||
}>(),
|
||||
{ allLabel: undefined, placeholder: undefined, triggerClass: undefined },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
/** ALL_SENTINEL is LabeledSelect's own internal Select-only stand-in for modelValue === '' — never exposed to a caller, which only ever sees '' itself via v-model. */
|
||||
const ALL_SENTINEL = '__all__'
|
||||
|
||||
const labelId = `labeled-select-${useId()}`
|
||||
|
||||
const selectValue = computed(() => (props.modelValue === '' && props.allLabel !== undefined ? ALL_SENTINEL : props.modelValue))
|
||||
|
||||
function onUpdate(value: unknown): void {
|
||||
if (typeof value !== 'string') return
|
||||
emit('update:modelValue', value === ALL_SENTINEL ? '' : value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label :id="labelId" class="text-xs font-medium text-muted-foreground">{{ label }}</label>
|
||||
<Select :model-value="selectValue" @update:model-value="onUpdate">
|
||||
<SelectTrigger :aria-labelledby="labelId" :class="triggerClass">
|
||||
<SelectValue :placeholder="placeholder" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-if="allLabel !== undefined" :value="ALL_SENTINEL">{{ allLabel }}</SelectItem>
|
||||
<SelectItem v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import SkeletonChart from '@/components/SkeletonChart.vue'
|
||||
import SkeletonList from '@/components/SkeletonList.vue'
|
||||
import SkeletonTable from '@/components/SkeletonTable.vue'
|
||||
import type { LoadState } from '@/lib/load-state'
|
||||
|
||||
/**
|
||||
* LoadStateView (reuse-audit.md F2) is the ONE skeleton/error/(empty)/ready
|
||||
* state branch for a list/table card with no Card wrapper of its own
|
||||
* opinions (ChartCard.vue is this same idea for a chart card) — TargetCallers,
|
||||
* HomePage's Latest-events card, and PricingHealthTable each used to
|
||||
* hand-roll this identical `v-if="state==='skeleton'" / v-else-if=
|
||||
* "state==='error'" / v-else-if="<own empty boolean>" / v-else` chain.
|
||||
* `state` decides skeleton/error/ready; the 'empty' branch is opt-in only —
|
||||
* it renders when the caller passes `empty: true` explicitly, never merely
|
||||
* because `state === 'empty'`. Every pre-existing call site already derived
|
||||
* its own "nothing to show" from its own data length (e.g. a settled fetch
|
||||
* that came back with zero rows), exactly like their old hand-rolled
|
||||
* chains did — `state === 'empty'` alone (no data ever loaded, not
|
||||
* loading, no error) must fall through to the ready branch (`v-else`), so
|
||||
* a caller that reaches this component only for its own
|
||||
* table/list-with-empty-message body (e.g. UsageTable, DataTable) keeps
|
||||
* rendering that body — and that body's own empty-message — unchanged,
|
||||
* instead of losing it to a generic "Nothing to show." here. `#empty`
|
||||
* overrides the default EmptyState entirely (TryLongerRangeButton, a
|
||||
* custom icon) when a plain title/description is not enough.
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
state: LoadState
|
||||
error?: string
|
||||
onRetry?: () => void | Promise<void>
|
||||
skeleton: 'table' | 'list' | 'chart'
|
||||
rows?: number
|
||||
cols?: number
|
||||
/** The ONLY switch for the empty branch — see this component's own doc comment. `state === 'empty'` alone never triggers it. */
|
||||
empty?: boolean
|
||||
emptyIcon?: IconDefinition
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}>(),
|
||||
{
|
||||
error: '',
|
||||
onRetry: undefined,
|
||||
rows: 5,
|
||||
cols: 4,
|
||||
empty: false,
|
||||
emptyIcon: undefined,
|
||||
emptyTitle: 'Nothing to show.',
|
||||
emptyDescription: undefined,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SkeletonTable v-if="state === 'skeleton' && skeleton === 'table'" :rows="rows" :cols="cols" />
|
||||
<SkeletonList v-else-if="state === 'skeleton' && skeleton === 'list'" :rows="rows" />
|
||||
<SkeletonChart v-else-if="state === 'skeleton'" />
|
||||
<ErrorState v-else-if="state === 'error'" :message="error" :on-retry="onRetry" />
|
||||
<slot v-else-if="empty" name="empty">
|
||||
<EmptyState :icon="emptyIcon" :title="emptyTitle" :description="emptyDescription" />
|
||||
</slot>
|
||||
<slot v-else />
|
||||
</template>
|
||||
@@ -6,14 +6,12 @@ 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 { Button } from '@/components/ui/button'
|
||||
import StatDisabledAlert from '@/components/StatDisabledAlert.vue'
|
||||
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
|
||||
@@ -41,8 +39,6 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{ 'update:query': [value: string] }>()
|
||||
|
||||
const nav = useNavStore()
|
||||
|
||||
const filtered = computed<ModelCatalogRow[]>(() => filterModelRows(props.rows, props.query))
|
||||
|
||||
const columns = modelCatalogColumns()
|
||||
@@ -82,13 +78,12 @@ const emptyMessage = computed<string>(() => {
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<Alert v-if="!latencyEnabled" variant="warn">
|
||||
<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>
|
||||
<StatDisabledAlert
|
||||
v-if="!latencyEnabled"
|
||||
title="Latency statistics are off"
|
||||
config-key="admin.stats.latency"
|
||||
purpose="to see fleet p50/p95."
|
||||
/>
|
||||
<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." />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { faCheck, faCopy, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, onUnmounted, ref, useTemplateRef } from 'vue'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useCopyFeedback } from '@/composables/useCopyFeedback'
|
||||
import { copyText } from '@/lib/clipboard'
|
||||
import { formatContextWindow, formatModelCostHover } from '@/lib/format'
|
||||
import { useToastsStore } from '@/stores/toasts'
|
||||
@@ -50,17 +51,9 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const labelRef = useTemplateRef<HTMLElement>('label')
|
||||
const state = ref<'idle' | 'copied' | 'selected' | 'failed'>('idle')
|
||||
let resetTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const { state, set: setCopyFeedback } = useCopyFeedback()
|
||||
const toasts = useToastsStore()
|
||||
|
||||
function scheduleReset(): void {
|
||||
clearTimeout(resetTimer)
|
||||
resetTimer = setTimeout(() => {
|
||||
state.value = 'idle'
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
async function onClick(): Promise<void> {
|
||||
const result = await copyText(props.id, labelRef.value ?? undefined)
|
||||
// 'failed' (neither the Clipboard API nor the selection fallback
|
||||
@@ -71,9 +64,8 @@ async function onClick(): Promise<void> {
|
||||
// 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
|
||||
setCopyFeedback(result)
|
||||
if (result === 'failed') toasts.push({ kind: 'error', message: `Could not copy ${props.id}.` })
|
||||
scheduleReset()
|
||||
}
|
||||
|
||||
const icon = {
|
||||
@@ -119,10 +111,6 @@ const title = computed(() => {
|
||||
const base = baseTitle.value[state.value]
|
||||
return metaDetail.value ? `${base} · ${metaDetail.value}` : base
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(resetTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -2,18 +2,17 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
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 LoadStateView from '@/components/LoadStateView.vue'
|
||||
import TryLongerRangeButton from '@/components/TryLongerRangeButton.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { EMPTY_CELL, optionalColumn, usageMetricColumns } from '@/lib/columns'
|
||||
import { toCsv } from '@/lib/csv'
|
||||
import { formatContextWindow, formatCost, formatModelCostHover } from '@/lib/format'
|
||||
import { formatContextWindow, formatModelCostHover } from '@/lib/format'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
import { joinPricingHealth, pricingHealthOrder } from '@/lib/pricing-health'
|
||||
import type { PricingHealthRow } from '@/lib/pricing-health'
|
||||
@@ -73,10 +72,14 @@ function sourceCell(row: PricingHealthRow) {
|
||||
}
|
||||
|
||||
function priceCell(row: PricingHealthRow) {
|
||||
if (row.inputPerMTokUsd === undefined || row.outputPerMTokUsd === undefined) return h('span', { class: 'text-muted-foreground' }, '—')
|
||||
if (row.inputPerMTokUsd === undefined || row.outputPerMTokUsd === undefined) return h('span', { class: 'text-muted-foreground' }, EMPTY_CELL)
|
||||
return h('span', {}, formatModelCostHover(row.inputPerMTokUsd, row.outputPerMTokUsd))
|
||||
}
|
||||
|
||||
// reuse-audit.md F9: context/requests/tokensIn/tokensOut/cost are the
|
||||
// identical shape lib/model-table-columns.ts's own modelCatalogColumns
|
||||
// builds for ModelCatalogRow — shared via lib/columns.ts's generic
|
||||
// factories rather than a second hand-rolled copy for this row type.
|
||||
const columns: ColumnDef<PricingHealthRow, unknown>[] = [
|
||||
{
|
||||
id: 'id',
|
||||
@@ -96,41 +99,8 @@ const columns: ColumnDef<PricingHealthRow, unknown>[] = [
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => priceCell(row.original),
|
||||
},
|
||||
{
|
||||
id: 'context',
|
||||
header: 'Context',
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (r) => r.contextTokens ?? 0,
|
||||
cell: ({ row }) => h('span', { class: 'tabular-nums' }, row.original.contextTokens !== undefined ? formatContextWindow(row.original.contextTokens) : '—'),
|
||||
},
|
||||
{
|
||||
id: 'requests',
|
||||
header: 'Requests',
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (r) => r.requests,
|
||||
cell: ({ row }) => h(CompactNumber, { value: row.original.requests }),
|
||||
},
|
||||
{
|
||||
id: 'tokensIn',
|
||||
header: 'Tokens in',
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (r) => r.tokensIn,
|
||||
cell: ({ row }) => h(CompactNumber, { value: row.original.tokensIn }),
|
||||
},
|
||||
{
|
||||
id: 'tokensOut',
|
||||
header: 'Tokens out',
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (r) => r.tokensOut,
|
||||
cell: ({ row }) => h(CompactNumber, { value: row.original.tokensOut }),
|
||||
},
|
||||
{
|
||||
id: 'cost',
|
||||
header: 'Cost',
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (r) => r.costMicroUsd,
|
||||
cell: ({ row }) => formatCost(row.original.costMicroUsd),
|
||||
},
|
||||
optionalColumn<PricingHealthRow>('context', 'Context', (r) => r.contextTokens, formatContextWindow),
|
||||
...usageMetricColumns<PricingHealthRow>(),
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -171,17 +141,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">
|
||||
<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.">
|
||||
<LoadStateView :state="state" :error="error" :on-retry="onRetry" skeleton="table" :rows="5" :cols="columns.length" :empty="rows.length === 0">
|
||||
<template #empty>
|
||||
<EmptyState title="No traffic in this range.">
|
||||
<TryLongerRangeButton />
|
||||
</EmptyState>
|
||||
<template v-else>
|
||||
</template>
|
||||
<DataTable :columns="columns" :data="rows" empty-message="none" />
|
||||
<div class="flex justify-end">
|
||||
<CsvExportButton filename="pricing-health.csv" :build="pricingHealthCsv" />
|
||||
</div>
|
||||
</template>
|
||||
</LoadStateView>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -8,16 +8,17 @@ import {
|
||||
import { computed, h, reactive, watch } from 'vue'
|
||||
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import DetailField from '@/components/DetailField.vue'
|
||||
import EmptyState from '@/components/EmptyState.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 StatDisabledAlert from '@/components/StatDisabledAlert.vue'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -356,22 +357,20 @@ const aliasEmptyMessage = computed(() =>
|
||||
<SearchInput v-model="modelQuery" placeholder="Search models or aliases..." class="mt-2 max-w-sm" />
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<Alert v-if="!models.latencyEnabled" variant="warn">
|
||||
<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>
|
||||
<StatDisabledAlert
|
||||
v-if="!models.latencyEnabled"
|
||||
title="Latency statistics are off"
|
||||
config-key="admin.stats.latency"
|
||||
purpose="to see fleet p50/p95."
|
||||
/>
|
||||
<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
|
||||
<EmptyState v-else-if="!overview?.providers.length" title="No providers configured." />
|
||||
<EmptyState
|
||||
v-else-if="hasQuery && filteredProviders.length === 0"
|
||||
class="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
no providers match "{{ modelQuery }}"
|
||||
</p>
|
||||
title="No matches."
|
||||
:description='`No providers match "${modelQuery}".`'
|
||||
/>
|
||||
<template v-else>
|
||||
<p class="text-xs text-muted-foreground">{{ perReplicaCaveat() }} Fleet p50/p95/timeouts/failovers below are fleet-wide, not per-replica.</p>
|
||||
<Accordion v-model="expandedProviderValues" type="multiple" class="rounded-md border px-3">
|
||||
@@ -488,22 +487,12 @@ const aliasEmptyMessage = computed(() =>
|
||||
</div>
|
||||
<AccordionContent>
|
||||
<dl class="mb-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">Base URL</dt>
|
||||
<dd class="break-all">{{ p.baseUrl }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">Last refresh</dt>
|
||||
<dd>{{ refreshDetailLabel(p.discoveryEnabled, p.lastRefresh) }}</dd>
|
||||
</div>
|
||||
<div v-if="p.healthState !== 'closed'">
|
||||
<dt class="text-xs text-muted-foreground">Discovery health</dt>
|
||||
<dd :class="healthDetailClass(p.healthState)">{{ healthBadgeLabel(p) }}</dd>
|
||||
</div>
|
||||
<div v-if="p.lastErr">
|
||||
<dt class="text-xs text-muted-foreground">Last error</dt>
|
||||
<dd class="text-destructive">{{ p.lastErr }}</dd>
|
||||
</div>
|
||||
<DetailField label="Base URL" class="break-all">{{ p.baseUrl }}</DetailField>
|
||||
<DetailField label="Last refresh">{{ refreshDetailLabel(p.discoveryEnabled, p.lastRefresh) }}</DetailField>
|
||||
<DetailField v-if="p.healthState !== 'closed'" label="Discovery health" :class="healthDetailClass(p.healthState)">
|
||||
{{ healthBadgeLabel(p) }}
|
||||
</DetailField>
|
||||
<DetailField v-if="p.lastErr" label="Last error" class="text-destructive">{{ p.lastErr }}</DetailField>
|
||||
</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 min-w-0 max-w-full items-center gap-1">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { faCheck, faCopy, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
|
||||
import { onUnmounted, ref, useTemplateRef } from 'vue'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useCopyFeedback } from '@/composables/useCopyFeedback'
|
||||
import { copyText } from '@/lib/clipboard'
|
||||
import { useToastsStore } from '@/stores/toasts'
|
||||
|
||||
@@ -21,21 +22,17 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const preRef = useTemplateRef<HTMLElement>('pre')
|
||||
const state = ref<'idle' | 'copied' | 'selected' | 'failed'>('idle')
|
||||
let resetTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const { state, set: setCopyFeedback } = useCopyFeedback()
|
||||
const toasts = useToastsStore()
|
||||
|
||||
async function onCopy(): Promise<void> {
|
||||
state.value = await copyText(props.text, preRef.value ?? undefined)
|
||||
const result = await copyText(props.text, preRef.value ?? undefined)
|
||||
setCopyFeedback(result)
|
||||
// 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'
|
||||
}, 1500)
|
||||
if (result === 'failed') toasts.push({ kind: 'error', message: 'Could not copy the snippet.' })
|
||||
}
|
||||
|
||||
const BUTTON_LABEL: Record<typeof state.value, string> = {
|
||||
@@ -44,8 +41,6 @@ const BUTTON_LABEL: Record<typeof state.value, string> = {
|
||||
selected: 'Selected — press ⌘/Ctrl+C',
|
||||
failed: 'Copy failed',
|
||||
}
|
||||
|
||||
onUnmounted(() => clearTimeout(resetTimer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
|
||||
/**
|
||||
* StatDisabledAlert (reuse-audit.md F3) is the ONE shared "a fleet
|
||||
* statistic is switched off in the middleware config" hint: a warn Alert
|
||||
* with a config-key `<code>` reference and an "Open Config" button that
|
||||
* navigates to the Config page. Replaces the 5 hand-rolled copies in
|
||||
* ProviderHealthPanel, ModelCatalogTable, ReliabilityPage,
|
||||
* AttributionDrilldown and UserDetail — those differed only in title,
|
||||
* config key and trailing purpose text (some dynamic, e.g. naming the
|
||||
* selected provider), which is exactly what these three props carry.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** e.g. "Latency statistics are off" or "Per-user-model statistics are off" */
|
||||
title: string
|
||||
/** dotted config key shown as inline code, e.g. "admin.stats.latency" */
|
||||
configKey: string
|
||||
/** trailing sentence after "Enable <code>{configKey}</code> in the middleware config ", e.g. "to see fleet p50/p95." */
|
||||
purpose: string
|
||||
}>()
|
||||
|
||||
const nav = useNavStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Alert variant="warn">
|
||||
<AlertTitle>{{ props.title }}</AlertTitle>
|
||||
<AlertDescription class="flex flex-wrap items-center gap-2">
|
||||
<span>Enable <code class="font-mono text-xs">{{ props.configKey }}</code> in the middleware config {{ props.purpose }}</span>
|
||||
<Button type="button" variant="outline" size="sm" @click="nav.goTo('config')">Open Config</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* StatItem (reuse-audit.md F6) is the ONE label-over-value stat cell every
|
||||
* "Total"/overview grid in this panel used to hand-roll: a muted label,
|
||||
* then either the value (default slot, rendered `text-lg font-semibold
|
||||
* tabular-nums`, optionally tinted by `tone`) or, when `hint` is set, a
|
||||
* small muted substitute line instead of a value — the "not enough data
|
||||
* yet"/"pick a reference model" case every month-projection or run-out
|
||||
* figure in this panel shows before it has enough data to report one.
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
tone?: 'default' | 'destructive' | 'accent' | 'muted'
|
||||
/** Shown as the value paragraph's `title` attribute — e.g. "Projected to exceed the configured cost/month limit." */
|
||||
title?: string
|
||||
/** A small muted line shown INSTEAD of the (default-slot) value — e.g. "not enough data yet". Omit to render the value normally. */
|
||||
hint?: string
|
||||
}>(),
|
||||
{ tone: 'default', title: undefined, hint: undefined },
|
||||
)
|
||||
|
||||
const TONE_CLASS: Record<'default' | 'destructive' | 'accent' | 'muted', string | undefined> = {
|
||||
default: undefined,
|
||||
destructive: 'text-destructive',
|
||||
accent: 'text-chart-requests',
|
||||
muted: 'text-muted-foreground',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<p class="text-muted-foreground">{{ label }}</p>
|
||||
<p v-if="hint" class="text-sm text-muted-foreground">{{ hint }}</p>
|
||||
<p v-else class="text-lg font-semibold tabular-nums" :class="TONE_CLASS[tone]" :title="title">
|
||||
<slot />
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,10 +3,9 @@ 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 LoadStateView from '@/components/LoadStateView.vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { adminFetch } from '@/lib/api'
|
||||
import { adminFetch, messageOf } from '@/lib/api'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
import { dayOrMonthWindow, totalsUrl } from '@/lib/range'
|
||||
import { targetCallerColumns } from '@/lib/target-columns'
|
||||
@@ -72,7 +71,7 @@ async function load(): Promise<void> {
|
||||
loadedOk.value = true
|
||||
} catch (err) {
|
||||
if (requestId !== reqId) return
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
error.value = messageOf(err)
|
||||
} finally {
|
||||
if (requestId === reqId) loading.value = false
|
||||
}
|
||||
@@ -98,9 +97,9 @@ const callersLoadState = computed(() => loadState({ loading: loading.value, hasD
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<ClampedRangeNotice :window="filters.window" :span="filters.span" />
|
||||
<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" />
|
||||
<LoadStateView :state="callersLoadState" :error="error" :on-retry="load" skeleton="table" :rows="5" :cols="columns.length">
|
||||
<DataTable :columns="columns" :data="rows" empty-message="no callers recorded in this window" />
|
||||
</LoadStateView>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -2,31 +2,30 @@
|
||||
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import BudgetRatioBars from '@/components/BudgetRatioBars.vue'
|
||||
import ChartCard from '@/components/ChartCard.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 StatDisabledAlert from '@/components/StatDisabledAlert.vue'
|
||||
import StatItem from '@/components/StatItem.vue'
|
||||
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'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
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 { formatBucketLabel, formatCost, formatLimits, formatShortDate, formatTimestamp } from '@/lib/format'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
import { BUDGET_RATIO_LABEL, budgetRatios } from '@/lib/usage-bars'
|
||||
import { budgetRatios, usdToMicros } 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
|
||||
@@ -42,7 +41,6 @@ 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)
|
||||
@@ -149,11 +147,6 @@ const recentErrors = computed(() => events.events.filter((e) => e.user === props
|
||||
|
||||
const ratios = computed(() => (entry.value ? budgetRatios(entry.value) : []))
|
||||
|
||||
function budgetValueText(id: string, used: number, limit: number): string {
|
||||
if (id.startsWith('cost')) return `${formatCost(used)} / ${formatCost(limit)}`
|
||||
return `${formatExactInt(used)} / ${formatExactInt(limit)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* runOutDate reuses lib/burndown.ts's own runOutDate — the SAME function
|
||||
* BurnDownChart.vue projects the Spend page's burn-down line with — rather
|
||||
@@ -163,7 +156,7 @@ function budgetValueText(id: string, used: number, limit: number): string {
|
||||
const runOutDate = computed(() => {
|
||||
const limitUsd = entry.value?.limits?.costPerMonthUSD
|
||||
if (!entry.value || !limitUsd) return null
|
||||
return computeRunOutDate(entry.value.costPerMonthMicroUsd, Math.round(limitUsd * 1_000_000), now.value)
|
||||
return computeRunOutDate(entry.value.costPerMonthMicroUsd, usdToMicros(limitUsd), now.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -191,18 +184,9 @@ const runOutDate = computed(() => {
|
||||
No usage scope for this user yet — it appears after their first request.
|
||||
</CardContent>
|
||||
<CardContent v-else class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Requests/day</p>
|
||||
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="entry.requestsPerDay" /></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Cost/day</p>
|
||||
<p class="text-lg font-semibold tabular-nums">{{ formatCost(entry.costPerDayMicroUsd) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Cost/month</p>
|
||||
<p class="text-lg font-semibold tabular-nums">{{ formatCost(entry.costPerMonthMicroUsd) }}</p>
|
||||
</div>
|
||||
<StatItem label="Requests/day"><CompactNumber :value="entry.requestsPerDay" /></StatItem>
|
||||
<StatItem label="Cost/day">{{ formatCost(entry.costPerDayMicroUsd) }}</StatItem>
|
||||
<StatItem label="Cost/month">{{ formatCost(entry.costPerMonthMicroUsd) }}</StatItem>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Limits</p>
|
||||
<p class="text-sm">{{ formatLimits(entry.limits) }}</p>
|
||||
@@ -213,41 +197,33 @@ const runOutDate = computed(() => {
|
||||
<Card v-if="entry && ratios.length">
|
||||
<CardHeader>
|
||||
<CardTitle>Headroom</CardTitle>
|
||||
<CardDescription v-if="runOutDate">At the current pace, cost/month runs out around {{ formatTimestamp(runOutDate.toISOString()) }}.</CardDescription>
|
||||
<CardDescription v-if="runOutDate">At the current pace, cost/month runs out around {{ formatShortDate(runOutDate) }}.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||
<span v-for="ratio in ratios" :key="ratio.id" class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{{ BUDGET_RATIO_LABEL[ratio.id] }}
|
||||
<UsageBar
|
||||
:ratio="ratio.ratio"
|
||||
:label="BUDGET_RATIO_LABEL[ratio.id]"
|
||||
:value-text="budgetValueText(ratio.id, ratio.used, ratio.limit)"
|
||||
<CardContent>
|
||||
<BudgetRatioBars :ratios="ratios" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ChartCard
|
||||
title="Requests over time"
|
||||
:state="reqState"
|
||||
:error="detail?.reqError ?? ''"
|
||||
:on-retry="loadDetail"
|
||||
:labels="reqLabels"
|
||||
:datasets="reqDatasets"
|
||||
:aria-label="`Requests over time for ${userId}`"
|
||||
/>
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Requests over time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cost over time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
<ChartCard
|
||||
title="Cost over time"
|
||||
:state="costState"
|
||||
:error="detail?.costError ?? ''"
|
||||
:on-retry="loadDetail"
|
||||
:labels="costLabels"
|
||||
:datasets="costDatasets"
|
||||
:value-formatter="formatCost"
|
||||
:aria-label="`Cost over time for ${userId}`"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -255,13 +231,12 @@ const runOutDate = computed(() => {
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<ClampedRangeNotice :window="filters.window" :span="filters.span" />
|
||||
<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 a per-model breakdown for this user.</span>
|
||||
<Button type="button" variant="outline" size="sm" @click="nav.goTo('config')">Open Config</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<StatDisabledAlert
|
||||
v-if="showUserModelHint"
|
||||
title="Per-user-model statistics are off"
|
||||
config-key="admin.stats.userModel"
|
||||
purpose="to see a per-model breakdown for this user."
|
||||
/>
|
||||
<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.">
|
||||
@@ -300,7 +275,7 @@ const runOutDate = computed(() => {
|
||||
<p class="text-muted-foreground">{{ event.message }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="py-6 text-center text-sm text-muted-foreground">none in the current event window</p>
|
||||
<EmptyState v-else title="No recent errors in the current event window." />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import TargetPicker from '@/components/forms/TargetPicker.vue'
|
||||
import SnippetBlock from '@/components/SnippetBlock.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { groupGrantSnippet, isValidName, isValidUserName, userGrantJsonFragment, validatePersonalGrant } from '@/lib/snippets'
|
||||
import { useTargetPicker } from '@/composables/useTargetPicker'
|
||||
import { groupGrantSnippet, parseList, userGrantJsonFragment, validatePersonalGrant } from '@/lib/snippets'
|
||||
|
||||
/**
|
||||
* GrantForm (ChangeHelper, redesign-plan.md section 3.4) builds a group's
|
||||
@@ -23,35 +24,20 @@ const props = defineProps<{
|
||||
userNames: string[]
|
||||
}>()
|
||||
|
||||
const targetKind = ref<'group' | 'user'>('group')
|
||||
const targetName = ref('')
|
||||
const { targetKind, targetName, targetNames, nameValid, snippetLabel } = useTargetPicker(
|
||||
() => props.groupNames,
|
||||
() => props.userNames,
|
||||
)
|
||||
const providersRaw = ref('')
|
||||
const modelsRaw = ref('')
|
||||
const mcpServersRaw = ref('')
|
||||
const agentsRaw = ref('')
|
||||
|
||||
/** parseList splits a comma-separated field into trimmed, non-empty, de-duplicated entries — the one shared parser every list field below uses. */
|
||||
function parseList(raw: string): string[] {
|
||||
const seen = new Set<string>()
|
||||
for (const part of raw.split(',')) {
|
||||
const trimmed = part.trim()
|
||||
if (trimmed) seen.add(trimmed)
|
||||
}
|
||||
return Array.from(seen)
|
||||
}
|
||||
|
||||
const providers = computed(() => parseList(providersRaw.value))
|
||||
const models = computed(() => parseList(modelsRaw.value))
|
||||
const mcpServers = computed(() => parseList(mcpServersRaw.value))
|
||||
const agents = computed(() => parseList(agentsRaw.value))
|
||||
|
||||
const targetNames = computed(() => (targetKind.value === 'group' ? props.groupNames : props.userNames))
|
||||
// A group name follows NAME_PATTERN (auth.go's configNamePattern — a
|
||||
// route-path-segment safety rule); a user name only needs to be
|
||||
// non-empty (auth.go's buildEntry, no character restriction at all —
|
||||
// P3 item 27). isValidUserName, not isValidName, for targetKind === 'user'.
|
||||
const nameValid = computed(() => (targetKind.value === 'group' ? isValidName(targetName.value.trim()) : isValidUserName(targetName.value)))
|
||||
|
||||
// validatePersonalGrant is a USER-ONLY rule (lib/snippets.ts's own doc
|
||||
// comment) — a group's own `providers: []` legitimately means "every
|
||||
// provider" and Go applies it with no such check at group-construction
|
||||
@@ -81,27 +67,14 @@ const snippet = computed<string | null>(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label id="grant-target-kind-label" class="text-xs font-medium text-muted-foreground">Target</label>
|
||||
<Select :model-value="targetKind" @update:model-value="(v) => (targetKind = v as 'group' | 'user')">
|
||||
<SelectTrigger aria-labelledby="grant-target-kind-label"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="group">Group</SelectItem>
|
||||
<SelectItem value="user">User (personal grant, existing users.json entry)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label id="grant-target-name-label" class="text-xs font-medium text-muted-foreground">{{ targetKind === 'group' ? 'Group name' : 'User name' }}</label>
|
||||
<Select :model-value="targetName" @update:model-value="(v) => (targetName = String(v))">
|
||||
<SelectTrigger aria-labelledby="grant-target-name-label"><SelectValue placeholder="Select…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="name in targetNames" :key="name" :value="name">{{ name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<TargetPicker
|
||||
:target-kind="targetKind"
|
||||
:target-name="targetName"
|
||||
:target-names="targetNames"
|
||||
user-option-label="User (personal grant, existing users.json entry)"
|
||||
@update:target-kind="targetKind = $event"
|
||||
@update:target-name="targetName = $event"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -128,11 +101,7 @@ const snippet = computed<string | null>(() => {
|
||||
<AlertDescription>{{ grantError }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SnippetBlock
|
||||
v-if="snippet"
|
||||
:label="targetKind === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName}’s users.json line`"
|
||||
:text="snippet"
|
||||
/>
|
||||
<SnippetBlock v-if="snippet" :label="snippetLabel" :text="snippet" />
|
||||
<p v-else class="text-sm text-muted-foreground">Pick a target and at least one list to build a snippet.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
import TargetPicker from '@/components/forms/TargetPicker.vue'
|
||||
import SnippetBlock from '@/components/SnippetBlock.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { groupLimitsSnippet, isValidName, isValidNonNegative, isValidNonNegativeInt, isValidUserName, userLimitsJsonFragment } from '@/lib/snippets'
|
||||
import { useTargetPicker } from '@/composables/useTargetPicker'
|
||||
import { groupLimitsSnippet, isValidNonNegative, isValidNonNegativeInt, userLimitsJsonFragment } from '@/lib/snippets'
|
||||
import type { LimitsConfig } from '@/types/api'
|
||||
|
||||
/**
|
||||
@@ -22,8 +23,10 @@ const props = defineProps<{
|
||||
userNames: string[]
|
||||
}>()
|
||||
|
||||
const targetKind = ref<'group' | 'user'>('group')
|
||||
const targetName = ref('')
|
||||
const { targetKind, targetName, targetNames, nameValid, snippetLabel } = useTargetPicker(
|
||||
() => props.groupNames,
|
||||
() => props.userNames,
|
||||
)
|
||||
|
||||
/** INTEGER_FIELDS is every LimitsConfig field Go decodes as `int64` (llmgateway.go) — requestsPerMinute/requestsPerDay/tokensPerDay/tokensPerMonth reject a fractional value like "1.5" at json.Unmarshal outright (P2 item 12), unlike costPerDayUSD/costPerMonthUSD, which are `float64`. */
|
||||
const INTEGER_FIELDS = new Set<keyof LimitsConfig>(['requestsPerMinute', 'requestsPerDay', 'tokensPerDay', 'tokensPerMonth'])
|
||||
@@ -51,8 +54,6 @@ const raw = reactive<Record<keyof LimitsConfig, string>>({
|
||||
costPerMonthUSD: '',
|
||||
})
|
||||
|
||||
const targetNames = computed(() => (targetKind.value === 'group' ? props.groupNames : props.userNames))
|
||||
|
||||
/** invalidFields lists every field whose entered text is non-empty but fails its own domain check (fieldValid above) — surfaced individually rather than one generic "invalid input" message, so the operator can see exactly which field to fix. Split by INTEGER_FIELDS so the error text is accurate for both: a fraction is invalid for requestsPerMinute etc. but perfectly valid for costPerDayUSD/costPerMonthUSD. */
|
||||
const invalidFields = computed(() =>
|
||||
FIELDS.filter(({ key }) => raw[key].trim() !== '' && !fieldValid(key, Number(raw[key]))).map((f) => f.label),
|
||||
@@ -73,12 +74,6 @@ const limits = computed<LimitsConfig>(() => {
|
||||
return out
|
||||
})
|
||||
|
||||
// A group name follows NAME_PATTERN (auth.go's configNamePattern — a
|
||||
// route-path-segment safety rule); a user name only needs to be
|
||||
// non-empty (auth.go's buildEntry, no character restriction at all —
|
||||
// P3 item 27). isValidUserName, not isValidName, for targetKind === 'user'.
|
||||
const nameValid = computed(() => (targetKind.value === 'group' ? isValidName(targetName.value.trim()) : isValidUserName(targetName.value)))
|
||||
|
||||
const snippet = computed<string | null>(() => {
|
||||
if (!nameValid.value || invalidFields.value.length > 0) return null
|
||||
if (Object.keys(limits.value).length === 0) return null
|
||||
@@ -90,27 +85,14 @@ const snippet = computed<string | null>(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label id="limits-target-kind-label" class="text-xs font-medium text-muted-foreground">Target</label>
|
||||
<Select :model-value="targetKind" @update:model-value="(v) => (targetKind = v as 'group' | 'user')">
|
||||
<SelectTrigger aria-labelledby="limits-target-kind-label"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="group">Group</SelectItem>
|
||||
<SelectItem value="user">User (existing users.json entry)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label id="limits-target-name-label" class="text-xs font-medium text-muted-foreground">{{ targetKind === 'group' ? 'Group name' : 'User name' }}</label>
|
||||
<Select :model-value="targetName" @update:model-value="(v) => (targetName = String(v))">
|
||||
<SelectTrigger aria-labelledby="limits-target-name-label"><SelectValue placeholder="Select…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="name in targetNames" :key="name" :value="name">{{ name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<TargetPicker
|
||||
:target-kind="targetKind"
|
||||
:target-name="targetName"
|
||||
:target-names="targetNames"
|
||||
user-option-label="User (existing users.json entry)"
|
||||
@update:target-kind="targetKind = $event"
|
||||
@update:target-name="targetName = $event"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div v-for="field in FIELDS" :key="field.key" class="flex flex-col gap-1">
|
||||
@@ -133,11 +115,7 @@ const snippet = computed<string | null>(() => {
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SnippetBlock
|
||||
v-if="snippet"
|
||||
:label="targetKind === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName}’s users.json line`"
|
||||
:text="snippet"
|
||||
/>
|
||||
<SnippetBlock v-if="snippet" :label="snippetLabel" :text="snippet" />
|
||||
<p v-else class="text-sm text-muted-foreground">Pick a target and at least one limit to build a snippet.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { GrantTargetKind } from '@/composables/useTargetPicker'
|
||||
|
||||
/**
|
||||
* TargetPicker (reuse-audit.md F12) is the "Target kind, then Group or
|
||||
* User name" two-Select block GrantForm.vue and LimitsForm.vue each
|
||||
* hand-rolled — pairs with the composables/useTargetPicker.ts composable,
|
||||
* which owns the state/validation this component only renders.
|
||||
* `userOptionLabel` stays a prop rather than a hardcoded string: GrantForm
|
||||
* ("User (personal grant, existing users.json entry)") and LimitsForm
|
||||
* ("User (existing users.json entry)") word the "user" option differently,
|
||||
* and unifying that wording is outside this refactor's scope.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
targetKind: GrantTargetKind
|
||||
targetName: string
|
||||
targetNames: string[]
|
||||
userOptionLabel: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:targetKind': [value: GrantTargetKind]
|
||||
'update:targetName': [value: string]
|
||||
}>()
|
||||
|
||||
const kindLabelId = `target-picker-kind-${useId()}`
|
||||
const nameLabelId = `target-picker-name-${useId()}`
|
||||
|
||||
function onKindUpdate(value: unknown): void {
|
||||
if (value === 'group' || value === 'user') emit('update:targetKind', value)
|
||||
}
|
||||
function onNameUpdate(value: unknown): void {
|
||||
emit('update:targetName', String(value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label :id="kindLabelId" class="text-xs font-medium text-muted-foreground">Target</label>
|
||||
<Select :model-value="targetKind" @update:model-value="onKindUpdate">
|
||||
<SelectTrigger :aria-labelledby="kindLabelId"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="group">Group</SelectItem>
|
||||
<SelectItem value="user">{{ props.userOptionLabel }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label :id="nameLabelId" class="text-xs font-medium text-muted-foreground">{{ targetKind === 'group' ? 'Group name' : 'User name' }}</label>
|
||||
<Select :model-value="targetName" @update:model-value="onNameUpdate">
|
||||
<SelectTrigger :aria-labelledby="nameLabelId"><SelectValue placeholder="Select…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="name in targetNames" :key="name" :value="name">{{ name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import LabeledSelect from '@/components/LabeledSelect.vue'
|
||||
import SnippetBlock from '@/components/SnippetBlock.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
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 { API_KEY_PLACEHOLDER, generateApiKey, isValidUserName, parseList, userJsonLine, validatePersonalGrant } from '@/lib/snippets'
|
||||
import { useToastsStore } from '@/stores/toasts'
|
||||
|
||||
/**
|
||||
@@ -21,6 +21,8 @@ import { useToastsStore } from '@/stores/toasts'
|
||||
*/
|
||||
const props = defineProps<{ groupNames: string[] }>()
|
||||
|
||||
const groupOptions = computed(() => props.groupNames.map((g) => ({ value: g, label: g })))
|
||||
|
||||
const name = ref('')
|
||||
const apiKey = ref(API_KEY_PLACEHOLDER)
|
||||
const primaryGroup = ref('')
|
||||
@@ -51,15 +53,6 @@ function generate(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function parseList(raw: string): string[] {
|
||||
const seen = new Set<string>()
|
||||
for (const part of raw.split(',')) {
|
||||
const trimmed = part.trim()
|
||||
if (trimmed) seen.add(trimmed)
|
||||
}
|
||||
return Array.from(seen)
|
||||
}
|
||||
|
||||
const extraGroups = computed(() => parseList(extraGroupsRaw.value))
|
||||
const providers = computed(() => parseList(providersRaw.value))
|
||||
const models = computed(() => parseList(modelsRaw.value))
|
||||
@@ -91,15 +84,13 @@ const snippet = computed<string | null>(() => {
|
||||
<label for="user-name" class="text-xs font-medium text-muted-foreground">Name</label>
|
||||
<Input id="user-name" v-model="name" placeholder="alice" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label id="user-group-label" class="text-xs font-medium text-muted-foreground">Primary group</label>
|
||||
<Select :model-value="primaryGroup" @update:model-value="(v) => (primaryGroup = String(v))">
|
||||
<SelectTrigger aria-labelledby="user-group-label"><SelectValue placeholder="Select…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="g in props.groupNames" :key="g" :value="g">{{ g }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<LabeledSelect
|
||||
label="Primary group"
|
||||
:model-value="primaryGroup"
|
||||
:options="groupOptions"
|
||||
placeholder="Select…"
|
||||
@update:model-value="primaryGroup = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCopyFeedback } from './useCopyFeedback'
|
||||
|
||||
describe('useCopyFeedback', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts idle', () => {
|
||||
const { state } = useCopyFeedback()
|
||||
expect(state.value).toBe('idle')
|
||||
})
|
||||
|
||||
it.each(['copied', 'selected', 'failed'] as const)('set(%s) records that outcome', (outcome) => {
|
||||
const { state, set } = useCopyFeedback()
|
||||
set(outcome)
|
||||
expect(state.value).toBe(outcome)
|
||||
})
|
||||
|
||||
it('reverts to idle after 1500ms', () => {
|
||||
const { state, set } = useCopyFeedback()
|
||||
set('copied')
|
||||
vi.advanceTimersByTime(1499)
|
||||
expect(state.value).toBe('copied')
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(state.value).toBe('idle')
|
||||
})
|
||||
|
||||
it('a second set() before the first reset restarts the 1500ms window rather than stacking timers', () => {
|
||||
const { state, set } = useCopyFeedback()
|
||||
set('copied')
|
||||
vi.advanceTimersByTime(1000)
|
||||
set('selected')
|
||||
vi.advanceTimersByTime(1000)
|
||||
// 2000ms since the first set(), but only 1000ms since the second —
|
||||
// still showing the second outcome, not yet reset.
|
||||
expect(state.value).toBe('selected')
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(state.value).toBe('idle')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { onUnmounted, ref, type Ref } from 'vue'
|
||||
|
||||
/** RESET_MS is how long a copy-feedback state (`copied`/`selected`/`failed`) stays visible before reverting to `idle` — SnippetBlock.vue's and ModelChip.vue's own identical 1500ms. */
|
||||
const RESET_MS = 1500
|
||||
|
||||
/** CopyFeedbackState mirrors lib/clipboard.ts's copyText own return union, plus the resting `idle` state neither SnippetBlock.vue nor ModelChip.vue had a name for before this composable. */
|
||||
export type CopyFeedbackState = 'idle' | 'copied' | 'selected' | 'failed'
|
||||
|
||||
export interface UseCopyFeedback {
|
||||
/** The current feedback state — drives a caller's own icon/label swap. */
|
||||
state: Ref<CopyFeedbackState>
|
||||
/** Records a copyText() outcome and schedules the reset-to-idle timer, clearing any still-pending one first (a rapid double-click restarts the window rather than stacking two timers). */
|
||||
set: (result: 'copied' | 'selected' | 'failed') => void
|
||||
}
|
||||
|
||||
/**
|
||||
* useCopyFeedback (reuse-audit.md F10) is the ONE "show a transient copy
|
||||
* outcome, then revert to idle after 1500ms" composable SnippetBlock.vue
|
||||
* and ModelChip.vue used to each hand-roll — the same `state` ref/
|
||||
* `resetTimer`/`clearTimeout`-on-unmount triple, with the identical
|
||||
* 1500ms magic number declared independently in both. Calls
|
||||
* `onUnmounted` itself (this composable is only ever invoked from a
|
||||
* component's own `setup()`/`<script setup>`, same as every other
|
||||
* composable in this panel), so a caller no longer needs its own cleanup
|
||||
* hook just for this timer.
|
||||
*/
|
||||
export function useCopyFeedback(): UseCopyFeedback {
|
||||
const state = ref<CopyFeedbackState>('idle')
|
||||
let resetTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function set(result: 'copied' | 'selected' | 'failed'): void {
|
||||
state.value = result
|
||||
clearTimeout(resetTimer)
|
||||
resetTimer = setTimeout(() => {
|
||||
state.value = 'idle'
|
||||
}, RESET_MS)
|
||||
}
|
||||
|
||||
onUnmounted(() => clearTimeout(resetTimer))
|
||||
|
||||
return { state, set }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useTargetPicker } from './useTargetPicker'
|
||||
|
||||
describe('useTargetPicker', () => {
|
||||
it('defaults to targetKind "group" with an empty targetName', () => {
|
||||
const picker = useTargetPicker(() => ['eng'], () => ['alice'])
|
||||
expect(picker.targetKind.value).toBe('group')
|
||||
expect(picker.targetName.value).toBe('')
|
||||
})
|
||||
|
||||
it('targetNames reads groupNames() while targetKind is "group", userNames() while "user"', () => {
|
||||
const picker = useTargetPicker(() => ['eng', 'ops'], () => ['alice', 'bob'])
|
||||
expect(picker.targetNames.value).toEqual(['eng', 'ops'])
|
||||
picker.targetKind.value = 'user'
|
||||
expect(picker.targetNames.value).toEqual(['alice', 'bob'])
|
||||
})
|
||||
|
||||
it('nameValid applies isValidName for a group target', () => {
|
||||
const picker = useTargetPicker(() => [], () => [])
|
||||
picker.targetName.value = 'valid-name.1'
|
||||
expect(picker.nameValid.value).toBe(true)
|
||||
picker.targetName.value = 'has spaces'
|
||||
expect(picker.nameValid.value).toBe(false)
|
||||
})
|
||||
|
||||
it('nameValid applies isValidUserName (non-empty only) for a user target', () => {
|
||||
const picker = useTargetPicker(() => [], () => [])
|
||||
picker.targetKind.value = 'user'
|
||||
picker.targetName.value = 'has spaces are fine for a user'
|
||||
expect(picker.nameValid.value).toBe(true)
|
||||
picker.targetName.value = ' '
|
||||
expect(picker.nameValid.value).toBe(false)
|
||||
})
|
||||
|
||||
it('snippetLabel names middleware.yaml for a group target', () => {
|
||||
const picker = useTargetPicker(() => [], () => [])
|
||||
expect(picker.snippetLabel.value).toBe('Paste into middleware.yaml')
|
||||
})
|
||||
|
||||
it('snippetLabel names the user\'s users.json line, with the current target name, for a user target', () => {
|
||||
const picker = useTargetPicker(() => [], () => [])
|
||||
picker.targetKind.value = 'user'
|
||||
picker.targetName.value = 'alice'
|
||||
expect(picker.snippetLabel.value).toBe('Merge into alice’s users.json line')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { computed, ref, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
import { isValidName, isValidUserName } from '@/lib/snippets'
|
||||
|
||||
/** GrantTargetKind — named to avoid colliding with lib/target-columns.ts's own unrelated TargetKind ('mcp' | 'agent'). */
|
||||
export type GrantTargetKind = 'group' | 'user'
|
||||
|
||||
export interface UseTargetPicker {
|
||||
targetKind: Ref<GrantTargetKind>
|
||||
targetName: Ref<string>
|
||||
/** Every configured name for the CURRENT targetKind — groupNames() while 'group', userNames() while 'user'. */
|
||||
targetNames: ComputedRef<string[]>
|
||||
/** A group name follows NAME_PATTERN (isValidName); a user name only needs to be non-empty (isValidUserName) — auth.go's own two different name rules. */
|
||||
nameValid: ComputedRef<boolean>
|
||||
/** The SnippetBlock label every ChangeHelper form using this picker shows once its snippet is ready. */
|
||||
snippetLabel: ComputedRef<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* useTargetPicker (reuse-audit.md F12) is the ONE "Target kind, then Group
|
||||
* or User name" picker GrantForm.vue and LimitsForm.vue used to each
|
||||
* hand-roll — the same targetKind/targetName refs, targetNames/nameValid
|
||||
* computeds (down to the identical doc comments), and SnippetBlock label
|
||||
* ternary. `groupNames`/`userNames` are getters, not plain arrays, so a
|
||||
* caller can pass `() => props.groupNames` and keep this reactive to a
|
||||
* prop that changes after mount (destructuring `props.groupNames`
|
||||
* directly would freeze it at the value read at call time).
|
||||
*/
|
||||
export function useTargetPicker(groupNames: () => string[], userNames: () => string[]): UseTargetPicker {
|
||||
const targetKind = ref<GrantTargetKind>('group')
|
||||
const targetName = ref('')
|
||||
|
||||
const targetNames = computed(() => (targetKind.value === 'group' ? groupNames() : userNames()))
|
||||
|
||||
const nameValid = computed(() => (targetKind.value === 'group' ? isValidName(targetName.value.trim()) : isValidUserName(targetName.value)))
|
||||
|
||||
const snippetLabel = computed(() =>
|
||||
targetKind.value === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName.value}’s users.json line`,
|
||||
)
|
||||
|
||||
return { targetKind, targetName, targetNames, nameValid, snippetLabel }
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AdminApiError, adminFetch } from './api'
|
||||
import { AdminApiError, adminFetch, isAuthRejection, messageOf } from './api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
@@ -76,3 +76,28 @@ describe('adminFetch', () => {
|
||||
expect(auth.error).toBe('invalid key, or not an admin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('messageOf', () => {
|
||||
it('returns an Error instance\'s own message', () => {
|
||||
expect(messageOf(new Error('boom'))).toBe('boom')
|
||||
})
|
||||
|
||||
it('stringifies a non-Error thrown value', () => {
|
||||
expect(messageOf('plain string')).toBe('plain string')
|
||||
expect(messageOf(404)).toBe('404')
|
||||
expect(messageOf(null)).toBe('null')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAuthRejection', () => {
|
||||
it('is true for an AdminApiError with status 401 or 403', () => {
|
||||
expect(isAuthRejection(new AdminApiError('nope', 401))).toBe(true)
|
||||
expect(isAuthRejection(new AdminApiError('nope', 403))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for any other AdminApiError status or a non-AdminApiError value', () => {
|
||||
expect(isAuthRejection(new AdminApiError('server error', 500))).toBe(false)
|
||||
expect(isAuthRejection(new Error('plain'))).toBe(false)
|
||||
expect(isAuthRejection('nope')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,3 +61,26 @@ export async function adminFetch<T>(path: string): Promise<T> {
|
||||
}
|
||||
return (await resp.json()) as T
|
||||
}
|
||||
|
||||
/**
|
||||
* messageOf (reuse-audit.md F4 step 1) extracts a human-readable message
|
||||
* from a caught value of unknown shape: an Error's own `.message`, or the
|
||||
* stringified value otherwise (a thrown string, object, etc.). The ONE
|
||||
* shared implementation — every Pinia store's fetch action and every
|
||||
* component with a local latest-request-wins fetch used to hand-roll this
|
||||
* same ternary in its `catch` block.
|
||||
*/
|
||||
export function messageOf(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
/**
|
||||
* isAuthRejection (reuse-audit.md F4 step 1) reports whether a caught
|
||||
* value is an AdminApiError for a 401/403 — i.e. adminFetch has already
|
||||
* rejected the stored key (authStore.reject) for this failure, so the
|
||||
* caller's own `catch` block should return early instead of also setting
|
||||
* its local `error` state with the same rejection.
|
||||
*/
|
||||
export function isAuthRejection(err: unknown): boolean {
|
||||
return err instanceof AdminApiError && (err.status === 401 || err.status === 403)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { BREAKDOWN_PALETTE, CHART_SERIES_COLORS, seriesColor } from './chart-palette'
|
||||
|
||||
describe('BREAKDOWN_PALETTE', () => {
|
||||
it('starts with the same four colors as CHART_SERIES_COLORS, in the same order', () => {
|
||||
expect(BREAKDOWN_PALETTE.slice(0, 4)).toEqual(CHART_SERIES_COLORS)
|
||||
})
|
||||
|
||||
it('has 8 entries total', () => {
|
||||
expect(BREAKDOWN_PALETTE).toHaveLength(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('seriesColor', () => {
|
||||
it('cycles through the default palette (CHART_SERIES_COLORS) by index', () => {
|
||||
expect(seriesColor(0)).toBe(CHART_SERIES_COLORS[0])
|
||||
expect(seriesColor(CHART_SERIES_COLORS.length)).toBe(CHART_SERIES_COLORS[0])
|
||||
expect(seriesColor(CHART_SERIES_COLORS.length + 1)).toBe(CHART_SERIES_COLORS[1])
|
||||
})
|
||||
|
||||
it('cycles through an explicitly passed palette (e.g. BREAKDOWN_PALETTE) instead', () => {
|
||||
expect(seriesColor(0, BREAKDOWN_PALETTE)).toBe(BREAKDOWN_PALETTE[0])
|
||||
expect(seriesColor(7, BREAKDOWN_PALETTE)).toBe(BREAKDOWN_PALETTE[7])
|
||||
expect(seriesColor(8, BREAKDOWN_PALETTE)).toBe(BREAKDOWN_PALETTE[0])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
// reuse-audit.md F13: the ONE chart colour palette module. Two call sites
|
||||
// used to each own a palette + its own cycling function — stores/
|
||||
// reliability.ts's RELIABILITY_SERIES_COLORS (a UI concern that had drifted
|
||||
// into a Pinia store) and pages/SpendPage.vue's BREAKDOWN_PALETTE (the
|
||||
// identical first four colours, plus four more) — this module is their one
|
||||
// shared home.
|
||||
|
||||
/**
|
||||
* CHART_SERIES_COLORS cycles through main.css's four existing --chart-*
|
||||
* tokens (assets/main.css is owned by WP-E — this module does not add a
|
||||
* fifth) — used to colour the Reliability page's per-provider error-rate/
|
||||
* timeout/failover overlay charts. With more than four configured
|
||||
* providers, colors repeat — a known, documented limitation rather than a
|
||||
* silent one.
|
||||
*/
|
||||
export const CHART_SERIES_COLORS = ['--chart-requests', '--chart-tokens-in', '--chart-tokens-out', '--chart-cost'] as const
|
||||
|
||||
/**
|
||||
* BREAKDOWN_PALETTE cycles a colorblind-safe (Okabe & Ito, 2008) 8-color
|
||||
* qualitative set across the Spend page's breakdown chart's per-model/
|
||||
* provider/group series. The first four reuse CHART_SERIES_COLORS (already
|
||||
* that same palette's requests/tokens-in/tokens-out/cost members); the
|
||||
* remaining four (--chart-series-a..d) extend it, defined alongside them in
|
||||
* assets/main.css — main.css's original four tokens were sized for four
|
||||
* FIXED semantic metrics, not an open-ended breakdown series count, so this
|
||||
* page needed four more, but every chart color token lives in the one
|
||||
* design-tokens file (Tailwind-only rule), never a component-local <style>
|
||||
* block. Beyond 8 series (e.g. a fleet with more than 8 configured groups)
|
||||
* colors repeat — an accepted qualitative-palette limit, not a bug: legend
|
||||
* labels remain the disambiguator past that point, the same tradeoff any
|
||||
* categorical chart palette makes once it runs out of distinguishable hues.
|
||||
*/
|
||||
export const BREAKDOWN_PALETTE = [
|
||||
...CHART_SERIES_COLORS,
|
||||
'--chart-series-a',
|
||||
'--chart-series-b',
|
||||
'--chart-series-c',
|
||||
'--chart-series-d',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* seriesColor picks one entry from `palette` by index, cycling — defaults
|
||||
* to CHART_SERIES_COLORS (the Reliability page's 4-color set); pass
|
||||
* BREAKDOWN_PALETTE for the Spend page's 8-color breakdown chart, or any
|
||||
* other palette a future chart needs.
|
||||
*/
|
||||
export function seriesColor(index: number, palette: readonly string[] = CHART_SERIES_COLORS): string {
|
||||
return palette[index % palette.length]!
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { compactColumn, costColumn, EMPTY_CELL, optionalColumn, usageMetricColumns } from './columns'
|
||||
|
||||
interface Row {
|
||||
requests: number
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
costMicroUsd: number
|
||||
latencyMs?: number
|
||||
}
|
||||
|
||||
describe('EMPTY_CELL', () => {
|
||||
it('is the em dash', () => {
|
||||
expect(EMPTY_CELL).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('compactColumn', () => {
|
||||
it('carries the given id/header and right-aligns', () => {
|
||||
const col = compactColumn<Row>('requests', 'Requests', (r) => r.requests)
|
||||
expect(col.id).toBe('requests')
|
||||
expect(col.header).toBe('Requests')
|
||||
expect(col.meta).toEqual({ align: 'right' })
|
||||
})
|
||||
|
||||
it('accessorFn reads the given field via the read function', () => {
|
||||
const col = compactColumn<Row>('requests', 'Requests', (r) => r.requests) as unknown as { accessorFn: (r: Row) => number }
|
||||
expect(col.accessorFn({ requests: 42, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 })).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('costColumn', () => {
|
||||
it('accessorFn reads the given field, unformatted', () => {
|
||||
const col = costColumn<Row>('cost', 'Cost', (r) => r.costMicroUsd) as unknown as { accessorFn: (r: Row) => number }
|
||||
expect(col.accessorFn({ requests: 0, tokensIn: 0, tokensOut: 0, costMicroUsd: 1_500_000 })).toBe(1_500_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('optionalColumn', () => {
|
||||
it('accessorFn falls back to the default sentinel (0) for an undefined value', () => {
|
||||
const col = optionalColumn<Row>('latency', 'Latency', (r) => r.latencyMs, String) as unknown as { accessorFn: (r: Row) => number }
|
||||
expect(col.accessorFn({ requests: 0, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 })).toBe(0)
|
||||
})
|
||||
|
||||
it('accessorFn falls back to a caller-supplied sortSentinel for an undefined value', () => {
|
||||
const col = optionalColumn<Row>('latency', 'Latency', (r) => r.latencyMs, String, { sortSentinel: -1 }) as unknown as {
|
||||
accessorFn: (r: Row) => number
|
||||
}
|
||||
expect(col.accessorFn({ requests: 0, tokensIn: 0, tokensOut: 0, costMicroUsd: 0 })).toBe(-1)
|
||||
})
|
||||
|
||||
it('accessorFn reads the real value when defined, ignoring the sentinel', () => {
|
||||
const col = optionalColumn<Row>('latency', 'Latency', (r) => r.latencyMs, String, { sortSentinel: -1 }) as unknown as {
|
||||
accessorFn: (r: Row) => number
|
||||
}
|
||||
expect(col.accessorFn({ requests: 0, tokensIn: 0, tokensOut: 0, costMicroUsd: 0, latencyMs: 120 })).toBe(120)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usageMetricColumns', () => {
|
||||
it('builds exactly requests/tokensIn/tokensOut/cost, in that order', () => {
|
||||
const columns = usageMetricColumns<Row>()
|
||||
expect(columns.map((c) => c.id)).toEqual(['requests', 'tokensIn', 'tokensOut', 'cost'])
|
||||
})
|
||||
|
||||
it('each column\'s accessorFn reads its own field', () => {
|
||||
const row: Row = { requests: 1, tokensIn: 2, tokensOut: 3, costMicroUsd: 4 }
|
||||
const columns = usageMetricColumns<Row>() as unknown as { id: string; accessorFn: (r: Row) => number }[]
|
||||
expect(columns.find((c) => c.id === 'requests')?.accessorFn(row)).toBe(1)
|
||||
expect(columns.find((c) => c.id === 'tokensIn')?.accessorFn(row)).toBe(2)
|
||||
expect(columns.find((c) => c.id === 'tokensOut')?.accessorFn(row)).toBe(3)
|
||||
expect(columns.find((c) => c.id === 'cost')?.accessorFn(row)).toBe(4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
// reuse-audit.md F9: generic TanStack ColumnDef factories for the numeric-
|
||||
// metric columns that repeat across every usage-shaped table in this panel
|
||||
// (lib/model-table-columns.ts, components/PricingHealthTable.vue, lib/
|
||||
// target-columns.ts) — a right-aligned, sortable count column rendered via
|
||||
// CompactNumber, a right-aligned cost column rendered via formatCost, and
|
||||
// an "undefined reads as a placeholder" optional numeric column (p50/p95,
|
||||
// context window). Each factory is generic over the row type `T`: the
|
||||
// duplication these replace was never about the ROW SHAPE (every caller
|
||||
// already has its own row interface), only about the identical id/header/
|
||||
// accessor/cell wiring built on top of it.
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { h } from 'vue'
|
||||
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import { formatCost } from '@/lib/format'
|
||||
|
||||
/** EMPTY_CELL is the ONE "no value to show" placeholder every table cell in this panel renders instead of a blank — model-table-columns.ts, PricingHealthTable.vue, usage-columns.ts, and events-columns.ts each used to redeclare the same '—' literal. */
|
||||
export const EMPTY_CELL = '—'
|
||||
|
||||
/** compactColumn builds one right-aligned, sortable numeric column rendered via CompactNumber (SI-style, exact value on title/aria-label) — the plain case with no storeDown masking or budget-bar overlay (usage-columns.ts's own richer numericColumn keeps those, see its own doc comment for why it is not built on this one). */
|
||||
export function compactColumn<T>(id: string, header: string, read: (row: T) => number): ColumnDef<T, unknown> {
|
||||
return {
|
||||
id,
|
||||
header,
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (r) => read(r),
|
||||
cell: ({ row }) => h(CompactNumber, { value: read(row.original) }),
|
||||
}
|
||||
}
|
||||
|
||||
/** costColumn builds one right-aligned, sortable micro-USD column rendered via formatCost — the plain case (a caller with its own special-case cell, e.g. model-table-columns.ts's free-priced-model badge, keeps its own hand-written column instead). */
|
||||
export function costColumn<T>(id: string, header: string, read: (row: T) => number): ColumnDef<T, unknown> {
|
||||
return {
|
||||
id,
|
||||
header,
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (r) => read(r),
|
||||
cell: ({ row }) => formatCost(read(row.original)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* optionalColumn builds one right-aligned, sortable column for a value
|
||||
* that may be undefined (no observations yet, a feature switched off) —
|
||||
* EMPTY_CELL for the cell, `sortSentinel` (default 0) for the accessor so
|
||||
* an "undefined" row still sorts somewhere deterministic (p50/p95 pass -1,
|
||||
* so "no data" sorts below a genuine 0ms reading rather than tying with
|
||||
* it; context window's own 0 default is fine as-is, nothing sorts below a
|
||||
* real context size of 0).
|
||||
*/
|
||||
export function optionalColumn<T>(
|
||||
id: string,
|
||||
header: string,
|
||||
read: (row: T) => number | undefined,
|
||||
format: (n: number) => string,
|
||||
opts?: { sortSentinel?: number },
|
||||
): ColumnDef<T, unknown> {
|
||||
const sentinel = opts?.sortSentinel ?? 0
|
||||
return {
|
||||
id,
|
||||
header,
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (r) => read(r) ?? sentinel,
|
||||
cell: ({ row }) => {
|
||||
const value = read(row.original)
|
||||
return h('span', { class: 'tabular-nums' }, value !== undefined ? format(value) : EMPTY_CELL)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** UsageMetricRow is the field set usageMetricColumns reads — every row shape in this panel that carries per-model/per-scope requests/tokens/cost (ModelCatalogRow, PricingHealthRow) already has exactly these four fields under these exact names. */
|
||||
export interface UsageMetricRow {
|
||||
requests: number
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
costMicroUsd: number
|
||||
}
|
||||
|
||||
/** usageMetricColumns builds the requests/tokensIn/tokensOut/cost column set every usage-ranked model table in this panel repeats (reuse-audit.md F9) — a caller with its own special-case cost cell (e.g. a free-priced-model badge) omits 'cost' here and appends its own instead. */
|
||||
export function usageMetricColumns<T extends UsageMetricRow>(): ColumnDef<T, unknown>[] {
|
||||
return [
|
||||
compactColumn<T>('requests', 'Requests', (r) => r.requests),
|
||||
compactColumn<T>('tokensIn', 'Tokens in', (r) => r.tokensIn),
|
||||
compactColumn<T>('tokensOut', 'Tokens out', (r) => r.tokensOut),
|
||||
costColumn<T>('cost', 'Cost', (r) => r.costMicroUsd),
|
||||
]
|
||||
}
|
||||
@@ -2,26 +2,22 @@ import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { h } from 'vue'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { EVENT_KIND_LABEL, eventKindVariant } from '@/lib/events-filter'
|
||||
import { EMPTY_CELL } from '@/lib/columns'
|
||||
import { eventKindVariant, kindLabel } from '@/lib/events-filter'
|
||||
import { formatTimestamp } from '@/lib/format'
|
||||
import type { AdminEventKind, AdminEventView } from '@/types/api'
|
||||
|
||||
/** kindLabel falls back to the raw kind string for a version-skewed server build emitting a kind EVENT_KIND_LABEL does not know — same "still render it" convention AdminEventView.kind's own `| string` union documents. */
|
||||
function kindLabel(kind: string): string {
|
||||
return EVENT_KIND_LABEL[kind as AdminEventKind] ?? kind
|
||||
}
|
||||
import type { AdminEventView } from '@/types/api'
|
||||
|
||||
/** scopeText renders one event's user/group — a user-scoped event never carries a group and vice versa (AdminEventView's own doc comment), so at most one of the two is ever present; an event with neither (a total/all-scope event, e.g. a capacity rejection recorded user-less — DECISIONS Q4) renders an em dash. */
|
||||
function scopeText(e: AdminEventView): string {
|
||||
if (e.user) return `user:${e.user}`
|
||||
if (e.group) return `group:${e.group}`
|
||||
return '—'
|
||||
return EMPTY_CELL
|
||||
}
|
||||
|
||||
/** targetText renders one event's model/provider pair — joined when both are known (an upstream/timeout event names both), just the one that is known otherwise, and an em dash when neither applies (a rate_limit/budget event never carries either). */
|
||||
function targetText(e: AdminEventView): string {
|
||||
const parts = [e.model, e.provider].filter((v): v is string => Boolean(v))
|
||||
return parts.length ? parts.join(' → ') : '—'
|
||||
return parts.length ? parts.join(' → ') : EMPTY_CELL
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +59,7 @@ export function eventsColumns(): ColumnDef<AdminEventView, unknown>[] {
|
||||
accessorFn: (e) => e.user ?? e.group ?? '',
|
||||
cell: ({ row }) => {
|
||||
const text = scopeText(row.original)
|
||||
return h('span', { class: text === '—' ? 'text-muted-foreground' : undefined }, text)
|
||||
return h('span', { class: text === EMPTY_CELL ? 'text-muted-foreground' : undefined }, text)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -73,7 +69,7 @@ export function eventsColumns(): ColumnDef<AdminEventView, unknown>[] {
|
||||
accessorFn: (e) => `${e.model ?? ''} ${e.provider ?? ''}`,
|
||||
cell: ({ row }) => {
|
||||
const text = targetText(row.original)
|
||||
return h('span', { class: text === '—' ? 'text-muted-foreground' : 'font-mono text-xs' }, text)
|
||||
return h('span', { class: text === EMPTY_CELL ? 'text-muted-foreground' : 'font-mono text-xs' }, text)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -88,7 +84,7 @@ export function eventsColumns(): ColumnDef<AdminEventView, unknown>[] {
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (e) => e.status ?? 0,
|
||||
cell: ({ row }) =>
|
||||
h('span', { class: 'tabular-nums' }, row.original.status !== undefined ? String(row.original.status) : '—'),
|
||||
h('span', { class: 'tabular-nums' }, row.original.status !== undefined ? String(row.original.status) : EMPTY_CELL),
|
||||
},
|
||||
{
|
||||
id: 'message',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { eventKindVariant, EVENT_KIND_LABEL, EVENT_KINDS, filterEvents } from './events-filter'
|
||||
import { eventKindVariant, EVENT_KIND_LABEL, EVENT_KINDS, filterEvents, kindLabel } from './events-filter'
|
||||
import type { AdminEventView } from '@/types/api'
|
||||
|
||||
function event(overrides: Partial<AdminEventView> = {}): AdminEventView {
|
||||
@@ -40,6 +40,18 @@ describe('EVENT_KINDS', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('kindLabel', () => {
|
||||
it('renders the known display label for every AdminEventKind', () => {
|
||||
for (const kind of EVENT_KINDS) {
|
||||
expect(kindLabel(kind)).toBe(EVENT_KIND_LABEL[kind])
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the raw kind string for a kind this build does not recognize', () => {
|
||||
expect(kindLabel('some_future_kind')).toBe('some_future_kind')
|
||||
})
|
||||
})
|
||||
|
||||
describe('eventKindVariant', () => {
|
||||
it.each([
|
||||
['rate_limit', 'destructive'],
|
||||
|
||||
@@ -26,6 +26,18 @@ export const EVENT_KIND_LABEL: Record<AdminEventKind, string> = {
|
||||
* there first).
|
||||
*/
|
||||
export const EVENT_KINDS = Object.keys(EVENT_KIND_LABEL) as AdminEventKind[]
|
||||
/**
|
||||
* kindLabel (reuse-audit.md F13) renders one event's kind as display text,
|
||||
* falling back to the raw kind string for a kind EVENT_KIND_LABEL does not
|
||||
* know (a server newer than this webui build) — same forward-compat
|
||||
* convention AdminEventView.kind's own `| string` union documents. The ONE
|
||||
* shared implementation: lib/events-columns.ts's Events-tab column and
|
||||
* HomePage.vue's "Latest events" card used to each hand-roll an identical
|
||||
* copy of this exact ternary.
|
||||
*/
|
||||
export function kindLabel(kind: string): string {
|
||||
return EVENT_KIND_LABEL[kind as AdminEventKind] ?? kind
|
||||
}
|
||||
|
||||
/**
|
||||
* eventKindVariant maps a kind to the shadcn-vue Badge variant its row
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { headroom, MIN_PROJECTION_FRACTION, monthProgress, projectMonthEnd } from './forecast'
|
||||
import { headroom, MIN_PROJECTION_FRACTION, monthCostProjection, monthProgress, NOT_ENOUGH_DATA, projectMonthEnd } from './forecast'
|
||||
|
||||
describe('monthProgress', () => {
|
||||
it('reads 0 at the first instant of a UTC month', () => {
|
||||
@@ -105,3 +105,35 @@ describe('headroom', () => {
|
||||
expect(headroom(5_000_000, 5)).toEqual({ limitMicros: 5_000_000, remainingMicros: 0, willExceed: false })
|
||||
})
|
||||
})
|
||||
describe('monthCostProjection', () => {
|
||||
const halfMonth = new Date('2026-04-16T00:00:00Z') // Apr 1 -> May 1 UTC, elapsed 15/30 = 0.5
|
||||
|
||||
it('returns micros: null, willExceed: false too early in the month to project (NOT_ENOUGH_DATA is the caller\'s own display text)', () => {
|
||||
const tooEarly = new Date('2026-04-01T00:00:00Z')
|
||||
expect(monthCostProjection({ costPerMonthMicroUsd: 1_000_000 }, tooEarly)).toEqual({ micros: null, willExceed: false })
|
||||
})
|
||||
|
||||
it('projects month-end cost and reports willExceed false when no limit is configured', () => {
|
||||
expect(monthCostProjection({ costPerMonthMicroUsd: 15_000_000 }, halfMonth)).toEqual({ micros: 30_000_000, willExceed: false })
|
||||
})
|
||||
|
||||
it('reports willExceed false when the projection stays under the entry\'s own configured limit', () => {
|
||||
expect(monthCostProjection({ costPerMonthMicroUsd: 15_000_000, limits: { costPerMonthUSD: 100 } }, halfMonth)).toEqual({
|
||||
micros: 30_000_000,
|
||||
willExceed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports willExceed true when the projection crosses the entry\'s own configured limit', () => {
|
||||
expect(monthCostProjection({ costPerMonthMicroUsd: 15_000_000, limits: { costPerMonthUSD: 10 } }, halfMonth)).toEqual({
|
||||
micros: 30_000_000,
|
||||
willExceed: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('NOT_ENOUGH_DATA', () => {
|
||||
it('is a non-empty display string', () => {
|
||||
expect(NOT_ENOUGH_DATA).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,11 +15,14 @@
|
||||
// with the figure actually being projected on the last/first day of a
|
||||
// month in any timezone west or east of UTC.
|
||||
|
||||
// MICROS_PER_USD (P11 review fix, DRY): imported from lib/usage-bars.ts,
|
||||
// the one shared definition — this module used to declare its own
|
||||
// identical copy, one of three (usage-bars.ts, usage-columns.ts) the
|
||||
// coordinator's review flagged.
|
||||
import { MICROS_PER_USD } from '@/lib/usage-bars'
|
||||
// usdToMicros (P11 review fix, DRY; reuse-audit.md F8): imported from
|
||||
// lib/usage-bars.ts, which re-exports lib/format.ts's own one shared
|
||||
// definition — this module used to declare its own identical MICROS_PER_USD
|
||||
// copy, one of three (usage-bars.ts, usage-columns.ts) an earlier review
|
||||
// flagged, then hand-rolled `Math.round(x * MICROS_PER_USD)` here even
|
||||
// after that consolidation (F8's own gap) instead of calling the shared
|
||||
// conversion function.
|
||||
import { usdToMicros } from '@/lib/usage-bars'
|
||||
|
||||
/**
|
||||
* MIN_PROJECTION_FRACTION is the smallest elapsed-month fraction
|
||||
@@ -100,7 +103,37 @@ export function headroom(projectedMicros: number | null, limitUsd: number | unde
|
||||
if (projectedMicros === null || limitUsd === undefined || limitUsd <= 0) {
|
||||
return { limitMicros: null, remainingMicros: null, willExceed: false }
|
||||
}
|
||||
const limitMicros = Math.round(limitUsd * MICROS_PER_USD)
|
||||
const limitMicros = usdToMicros(limitUsd)
|
||||
const remainingMicros = limitMicros - projectedMicros
|
||||
return { limitMicros, remainingMicros, willExceed: remainingMicros < 0 }
|
||||
}
|
||||
|
||||
/** NOT_ENOUGH_DATA (reuse-audit.md F7) is the ONE "too early in the month to project" message — projectMonthEnd's own null case, shown wherever a projected/run-out figure has nothing to report yet. */
|
||||
export const NOT_ENOUGH_DATA = 'not enough data yet'
|
||||
|
||||
/** WILL_EXCEED_TITLE (reuse-audit.md F7) is the ONE tooltip text for a projection that crosses its own configured limit (headroom's own willExceed). */
|
||||
export const WILL_EXCEED_TITLE = 'Projected to exceed the configured cost/month limit'
|
||||
|
||||
/** MonthCostProjection is monthCostProjection's own result: the projected month-end micro-USD total (null below MIN_PROJECTION_FRACTION, projectMonthEnd's own convention), and whether it crosses the entry's own configured cost/month limit. */
|
||||
export interface MonthCostProjection {
|
||||
micros: number | null
|
||||
willExceed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* monthCostProjection projects one usage entry's month-end cost
|
||||
* (projectMonthEnd) and checks it against that SAME entry's own configured
|
||||
* cost/month limit (headroom) in one call — the ONE per-row month-end
|
||||
* projection every table/detail-grid/CSV export in this panel used to
|
||||
* re-derive separately (ConsumerDirectory.vue's groupCostProjection,
|
||||
* lib/usage-columns.ts's costMonthProjColumn, lib/usage-csv.ts's own CSV
|
||||
* row), each pairing projectMonthEnd + headroom by hand. Takes only the
|
||||
* two fields it actually reads, not a full AdminUsageEntryView, so a
|
||||
* caller building one from a narrower shape (or a test) never needs to
|
||||
* fill in fields this has no use for.
|
||||
*/
|
||||
export function monthCostProjection(entry: { costPerMonthMicroUsd: number; limits?: { costPerMonthUSD?: number } }, now: Date): MonthCostProjection {
|
||||
const projected = projectMonthEnd(entry.costPerMonthMicroUsd, monthProgress(now))
|
||||
if (projected === null) return { micros: null, willExceed: false }
|
||||
return { micros: projected, willExceed: headroom(projected, entry.limits?.costPerMonthUSD).willExceed }
|
||||
}
|
||||
|
||||
+37
-1
@@ -18,9 +18,45 @@ export function routableModelId(providerName: string, modelId: string): string {
|
||||
return `${providerName}/${modelId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* MICROS_PER_USD converts a plain USD amount (e.g. a LimitsConfig cost
|
||||
* limit, `costPerDayUSD: 5`) into the micro-USD unit AdminUsageEntryView's
|
||||
* own cost fields already use — matching metrics.go's usdToMicros(round)
|
||||
* convention (ground truth, dashboard-plan.md section 0).
|
||||
*
|
||||
* reuse-audit.md F8: the ONE definition (moved here from lib/usage-bars.ts,
|
||||
* which re-exports it for every existing import site) — usdToMicros/
|
||||
* microsToUsd below are the ONE place a USD<->micro-USD conversion happens,
|
||||
* replacing the hand-rolled `Math.round(usd * 1_000_000)` / `micros /
|
||||
* 1_000_000` copies that used to bypass this constant.
|
||||
*/
|
||||
export const MICROS_PER_USD = 1_000_000
|
||||
|
||||
/** usdToMicros converts a plain USD amount to micro-USD, rounded to the nearest whole micro-USD (metrics.go's own usdToMicros(round) convention). */
|
||||
export function usdToMicros(usd: number): number {
|
||||
return Math.round(usd * MICROS_PER_USD)
|
||||
}
|
||||
|
||||
/** microsToUsd converts a micro-USD integer back to a plain USD float — the exact inverse of usdToMicros, modulo usdToMicros' own rounding. */
|
||||
export function microsToUsd(micros: number): number {
|
||||
return micros / MICROS_PER_USD
|
||||
}
|
||||
|
||||
/** formatCost renders a micro-USD integer (adminUsageEntryView's convention) as "$1.2345". */
|
||||
export function formatCost(micros: number): string {
|
||||
return `$${(micros / 1_000_000).toFixed(4)}`
|
||||
return `$${microsToUsd(micros).toFixed(4)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* formatShortDate renders a Date as a compact "Oct 15" label (month/day
|
||||
* only, no time-of-day) — reuse-audit.md F13: the ONE run-out-date format,
|
||||
* replacing BurnDownChart.vue's and UserDetail.vue's two disagreeing
|
||||
* formats (a short date vs. a full formatTimestamp). A PROJECTED date (a
|
||||
* run-out estimate) should never imply false time-of-day precision, so
|
||||
* this intentionally omits it.
|
||||
*/
|
||||
export function formatShortDate(d: Date): string {
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/** formatExactInt renders a raw integer count with thousands separators ("1,234,567") — the precise value CompactNumber.vue and the usage chart's tooltip carry alongside a compact rendering, never lost. */
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// boundaries for the same two underlying concepts (a used/limit ratio,
|
||||
// a success rate) the rest of this panel already colors consistently.
|
||||
import { providerRateStatus, providerSuccessRate } from '@/lib/provider-rate'
|
||||
import { MICROS_PER_USD, ratioTier } from '@/lib/usage-bars'
|
||||
import { ratioTier, usdToMicros } from '@/lib/usage-bars'
|
||||
import type { AdminGroupView, AdminProviderView } from '@/types/api'
|
||||
|
||||
/** KpiTier is KpiTile.vue's own three-color scale — 'ok' (normal), 'warn' (approaching a limit / minor degradation), 'critical' (over a limit / severe degradation). */
|
||||
@@ -27,7 +27,7 @@ export function sumGroupBudgetMicros(groups: AdminGroupView[]): number {
|
||||
let total = 0
|
||||
for (const group of groups) {
|
||||
const limitUsd = group.limits?.costPerMonthUSD
|
||||
if (limitUsd && limitUsd > 0) total += Math.round(limitUsd * MICROS_PER_USD)
|
||||
if (limitUsd && limitUsd > 0) total += usdToMicros(limitUsd)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { h } from 'vue'
|
||||
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import EntityLink from '@/components/EntityLink.vue'
|
||||
import ModelChip from '@/components/ModelChip.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { EMPTY_CELL, optionalColumn, usageMetricColumns } from '@/lib/columns'
|
||||
import { formatContextWindow, formatCost, formatLatencyMs, formatModelCostHover } from '@/lib/format'
|
||||
import { formatErrorRatePercent, providerErrorRate } from '@/lib/provider-rate'
|
||||
import type { AdminCatalogResponse, AdminPerfResponse, AdminUsageModelsResponse, PriceSource } from '@/types/api'
|
||||
@@ -117,7 +117,7 @@ function priceCell(row: ModelCatalogRow) {
|
||||
// captured into its own variable first.
|
||||
const source = row.priceSource as Exclude<PriceSource, 'unpriced' | 'free'>
|
||||
return h('span', { class: 'inline-flex flex-wrap items-center gap-1.5' }, [
|
||||
h('span', {}, known ? formatModelCostHover(row.inputPerMTokUsd as number, row.outputPerMTokUsd as number) : '—'),
|
||||
h('span', {}, known ? formatModelCostHover(row.inputPerMTokUsd as number, row.outputPerMTokUsd as number) : EMPTY_CELL),
|
||||
h(Badge, { as: 'span', variant: 'outline', class: 'font-normal' }, () => PRICE_SOURCE_LABEL[source]),
|
||||
])
|
||||
}
|
||||
@@ -162,18 +162,7 @@ export function modelCatalogColumns(): ColumnDef<ModelCatalogRow, unknown>[] {
|
||||
h(EntityLink, { label: row.original.id, kind: 'model', id: row.original.id }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'context',
|
||||
header: 'Context',
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (r) => r.contextTokens ?? 0,
|
||||
cell: ({ row }) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: 'tabular-nums' },
|
||||
row.original.contextTokens !== undefined ? formatContextWindow(row.original.contextTokens) : '—',
|
||||
),
|
||||
},
|
||||
optionalColumn<ModelCatalogRow>('context', 'Context', (r) => r.contextTokens, formatContextWindow),
|
||||
{
|
||||
id: 'price',
|
||||
header: 'Price',
|
||||
@@ -191,7 +180,7 @@ export function modelCatalogColumns(): ColumnDef<ModelCatalogRow, unknown>[] {
|
||||
{ as: 'span', variant: 'secondary', class: 'font-normal', title: "':free' suffix model — display-only, billing follows the Price column's own source" },
|
||||
() => 'free',
|
||||
)
|
||||
: h('span', { class: 'text-muted-foreground' }, '—'),
|
||||
: h('span', { class: 'text-muted-foreground' }, EMPTY_CELL),
|
||||
},
|
||||
{
|
||||
id: 'aliases',
|
||||
@@ -200,7 +189,7 @@ export function modelCatalogColumns(): ColumnDef<ModelCatalogRow, unknown>[] {
|
||||
accessorFn: (r) => r.aliases.join(', '),
|
||||
cell: ({ row }) => {
|
||||
const aliases = row.original.aliases
|
||||
if (aliases.length === 0) return h('span', { class: 'text-muted-foreground' }, '—')
|
||||
if (aliases.length === 0) return h('span', { class: 'text-muted-foreground' }, EMPTY_CELL)
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'flex flex-wrap gap-1' },
|
||||
@@ -208,27 +197,13 @@ export function modelCatalogColumns(): ColumnDef<ModelCatalogRow, unknown>[] {
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'requests',
|
||||
header: 'Requests',
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (r) => r.requests,
|
||||
cell: ({ row }) => h(CompactNumber, { value: row.original.requests }),
|
||||
},
|
||||
{
|
||||
id: 'tokensIn',
|
||||
header: 'Tokens in',
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (r) => r.tokensIn,
|
||||
cell: ({ row }) => h(CompactNumber, { value: row.original.tokensIn }),
|
||||
},
|
||||
{
|
||||
id: 'tokensOut',
|
||||
header: 'Tokens out',
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (r) => r.tokensOut,
|
||||
cell: ({ row }) => h(CompactNumber, { value: row.original.tokensOut }),
|
||||
},
|
||||
// reuse-audit.md F9: requests/tokensIn/tokensOut share the shared
|
||||
// usageMetricColumns factory; 'cost' stays hand-written below since
|
||||
// this table's cost cell has an extra free-priced-model badge case
|
||||
// usageMetricColumns' own plain costColumn does not (and should not —
|
||||
// PricingHealthTable.vue's identical-shaped cost column has no such
|
||||
// badge) carry.
|
||||
...usageMetricColumns<ModelCatalogRow>().filter((c) => c.id !== 'cost'),
|
||||
{
|
||||
id: 'cost',
|
||||
header: 'Cost',
|
||||
@@ -242,20 +217,8 @@ export function modelCatalogColumns(): ColumnDef<ModelCatalogRow, unknown>[] {
|
||||
return formatCost(r.costMicroUsd)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'p50',
|
||||
header: 'p50',
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (r) => r.p50Ms ?? -1,
|
||||
cell: ({ row }) => h('span', { class: 'tabular-nums' }, row.original.p50Ms !== undefined ? formatLatencyMs(row.original.p50Ms) : '—'),
|
||||
},
|
||||
{
|
||||
id: 'p95',
|
||||
header: 'p95',
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (r) => r.p95Ms ?? -1,
|
||||
cell: ({ row }) => h('span', { class: 'tabular-nums' }, row.original.p95Ms !== undefined ? formatLatencyMs(row.original.p95Ms) : '—'),
|
||||
},
|
||||
optionalColumn<ModelCatalogRow>('p50', 'p50', (r) => r.p50Ms, formatLatencyMs, { sortSentinel: -1 }),
|
||||
optionalColumn<ModelCatalogRow>('p95', 'p95', (r) => r.p95Ms, formatLatencyMs, { sortSentinel: -1 }),
|
||||
{
|
||||
id: 'errorRate',
|
||||
header: 'Error rate',
|
||||
|
||||
@@ -177,4 +177,8 @@ describe('formatErrorRatePercent', () => {
|
||||
it('does not clamp a rate above 1 (should not occur with real counters, but is not silently capped)', () => {
|
||||
expect(formatErrorRatePercent(1.8)).toBe('180%')
|
||||
})
|
||||
|
||||
it('renders null as the caller-supplied nullLabel instead of the "no data" default', () => {
|
||||
expect(formatErrorRatePercent(null, 'no traffic')).toBe('no traffic')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -118,13 +118,16 @@ export function providerErrorRate(attempts: number, failures: number): number |
|
||||
* formatRatePercent above (a SUCCESS rate must never overstate itself as
|
||||
* 100%; an ERROR rate must never round down to a misleadingly clean 0%).
|
||||
* `null` (no traffic to compute a rate from — providerErrorRate's own
|
||||
* "nothing to report" case) renders as "no data", never "0%". Not clamped
|
||||
* to 100%: a rate above 1 should not occur with real counters, but this
|
||||
* "nothing to report" case) renders as `nullLabel` (default "no data"),
|
||||
* never "0%" — reuse-audit.md F13: the `nullLabel` param lets HomePage.vue's
|
||||
* fleet-wide reading pass "no traffic" instead of hand-rolling an identical
|
||||
* copy of this function with a different null wording. Not clamped to
|
||||
* 100%: a rate above 1 should not occur with real counters, but this
|
||||
* function reports whatever the caller computed rather than silently
|
||||
* capping it (lib/series.ts's own ratioSeries makes the identical choice).
|
||||
*/
|
||||
export function formatErrorRatePercent(rate: number | null): string {
|
||||
if (rate === null) return 'no data'
|
||||
export function formatErrorRatePercent(rate: number | null, nullLabel = 'no data'): string {
|
||||
if (rate === null) return nullLabel
|
||||
if (rate <= 0) return '0%'
|
||||
return `${Math.ceil(rate * 100)}%`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { drilldownLevel, makeScope, parseScope, scopeBudgetLimits, scopeId } from './scope'
|
||||
import type { AdminGroupView, AdminUsageEntryView } from '@/types/api'
|
||||
|
||||
function group(overrides: Partial<AdminGroupView> = {}): AdminGroupView {
|
||||
return { name: 'eng', memberCount: 1, ...overrides }
|
||||
}
|
||||
|
||||
function user(overrides: Partial<AdminUsageEntryView> = {}): AdminUsageEntryView {
|
||||
return {
|
||||
kind: 'user',
|
||||
id: 'alice',
|
||||
requestsPerMinute: 0,
|
||||
requestsPerDay: 0,
|
||||
tokensInPerDay: 0,
|
||||
tokensOutPerDay: 0,
|
||||
tokensInPerMonth: 0,
|
||||
tokensOutPerMonth: 0,
|
||||
costPerDayMicroUsd: 0,
|
||||
costPerMonthMicroUsd: 0,
|
||||
rejectionsPerDay: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseScope', () => {
|
||||
it.each([
|
||||
['group:eng', { kind: 'group', id: 'eng' }],
|
||||
['user:alice', { kind: 'user', id: 'alice' }],
|
||||
['model:gpt-4', { kind: 'model', id: 'gpt-4' }],
|
||||
['provider:openai', { kind: 'provider', id: 'openai' }],
|
||||
] as const)('splits %s into its kind and id', (input, expected) => {
|
||||
expect(parseScope(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('returns null for a string with no recognized "kind:" prefix', () => {
|
||||
expect(parseScope('all')).toBeNull()
|
||||
expect(parseScope('total')).toBeNull()
|
||||
expect(parseScope('')).toBeNull()
|
||||
})
|
||||
|
||||
it('splits only on the FIRST colon, keeping the rest of the id intact', () => {
|
||||
expect(parseScope('model:openai/gpt-4:latest')).toEqual({ kind: 'model', id: 'openai/gpt-4:latest' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('scopeId', () => {
|
||||
it('strips the "kind:" prefix', () => {
|
||||
expect(scopeId('group:eng')).toBe('eng')
|
||||
expect(scopeId('provider:openai')).toBe('openai')
|
||||
})
|
||||
|
||||
it('returns the string unchanged when it carries no recognized prefix', () => {
|
||||
expect(scopeId('all')).toBe('all')
|
||||
expect(scopeId('total')).toBe('total')
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeScope', () => {
|
||||
it('is parseScope\'s own inverse', () => {
|
||||
expect(makeScope('group', 'eng')).toBe('group:eng')
|
||||
expect(parseScope(makeScope('user', 'alice'))).toEqual({ kind: 'user', id: 'alice' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('drilldownLevel', () => {
|
||||
it('reads "" as the group level', () => {
|
||||
expect(drilldownLevel('')).toEqual({ kind: 'group' })
|
||||
})
|
||||
|
||||
it('reads "group:x" as the user level, naming the parent group', () => {
|
||||
expect(drilldownLevel('group:eng')).toEqual({ kind: 'user', group: 'eng' })
|
||||
})
|
||||
|
||||
it('reads "user:x" as the usermodel level, naming the user', () => {
|
||||
expect(drilldownLevel('user:alice')).toEqual({ kind: 'usermodel', user: 'alice' })
|
||||
})
|
||||
|
||||
it('reads any other non-empty string as the usermodel level verbatim (defensive fallback)', () => {
|
||||
expect(drilldownLevel('alice')).toEqual({ kind: 'usermodel', user: 'alice' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('scopeBudgetLimits', () => {
|
||||
const groups = [group({ name: 'eng', limits: { costPerMonthUSD: 100 } }), group({ name: 'ops', limits: { costPerMonthUSD: 50 } })]
|
||||
const users = [user({ id: 'alice', limits: { costPerMonthUSD: 10 } })]
|
||||
|
||||
it('sums every configured group\'s own budget for "all" (or any other unscoped root string)', () => {
|
||||
expect(scopeBudgetLimits('all', groups, users)).toEqual({ costPerMonthUSD: 150 })
|
||||
expect(scopeBudgetLimits('total', groups, users)).toEqual({ costPerMonthUSD: 150 })
|
||||
})
|
||||
|
||||
it('returns undefined for the fleet-wide sum when no group has a budget configured', () => {
|
||||
expect(scopeBudgetLimits('all', [group({ limits: undefined })], [])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves a group scope to that one group\'s own limits', () => {
|
||||
expect(scopeBudgetLimits('group:eng', groups, users)).toEqual({ costPerMonthUSD: 100 })
|
||||
})
|
||||
|
||||
it('resolves a user scope to that one user\'s own limits', () => {
|
||||
expect(scopeBudgetLimits('user:alice', groups, users)).toEqual({ costPerMonthUSD: 10 })
|
||||
})
|
||||
|
||||
it('returns undefined for a group/user scope this fleet does not actually have', () => {
|
||||
expect(scopeBudgetLimits('group:nope', groups, users)).toBeUndefined()
|
||||
expect(scopeBudgetLimits('user:nope', groups, users)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for a provider scope (no single group/user limit applies)', () => {
|
||||
expect(scopeBudgetLimits('provider:openai', groups, users)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
// reuse-audit.md F5: scope-string parsing (the `group:`/`user:`/`model:`/
|
||||
// `provider:` prefix convention shared by stores/filters.ts's global scope
|
||||
// filter, AttributionDrilldown's `drill` breadcrumb, and every per-series
|
||||
// `scope` string an /admin/api/usage/* or /admin/api/events response
|
||||
// carries) used to be reimplemented at every call site: a prefix strip
|
||||
// three different ways, `drilldownLevel` duplicated verbatim, and a
|
||||
// scope-to-budget lookup written out twice more. Kept pure and out of any
|
||||
// component/store the same way lib/series.ts and lib/kpi.ts keep their own
|
||||
// math out of theirs — vitest's node-environment config exercises every
|
||||
// function here directly, no component mount required.
|
||||
import { sumGroupBudgetMicros } from '@/lib/kpi'
|
||||
import { microsToUsd } from '@/lib/usage-bars'
|
||||
import type { AdminGroupView, AdminUsageEntryView, LimitsConfig } from '@/types/api'
|
||||
|
||||
/** ScopeKind is every prefix a "kind:id" scope string can carry across this panel — not every caller accepts every kind (a Spend burn-down scope never carries 'provider', a Reliability series scope never carries 'group'/'user'), but the prefix syntax itself is the same everywhere. */
|
||||
export type ScopeKind = 'group' | 'user' | 'model' | 'provider'
|
||||
|
||||
export interface ParsedScope {
|
||||
kind: ScopeKind
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* parseScope splits a "kind:id" scope string into its parts. Returns null
|
||||
* for a string with no recognized "kind:" prefix — a caller's own root/
|
||||
* unscoped sentinel ('all', 'total', or AttributionDrilldown's drill ''),
|
||||
* which only that caller knows how to interpret, so this never guesses at
|
||||
* one.
|
||||
*/
|
||||
export function parseScope(scope: string): ParsedScope | null {
|
||||
for (const kind of ['group', 'user', 'model', 'provider'] as const) {
|
||||
const prefix = `${kind}:`
|
||||
if (scope.startsWith(prefix)) return { kind, id: scope.slice(prefix.length) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** scopeId strips a scope string's own "kind:" prefix, or returns it unchanged when it carries none (the caller's own root sentinel, or an id that was never prefixed to begin with). */
|
||||
export function scopeId(scope: string): string {
|
||||
return parseScope(scope)?.id ?? scope
|
||||
}
|
||||
|
||||
/** makeScope builds a "kind:id" scope string — parseScope's own inverse. */
|
||||
export function makeScope(kind: ScopeKind, id: string): string {
|
||||
return `${kind}:${id}`
|
||||
}
|
||||
|
||||
/** DrilldownLevel is AttributionDrilldown's current GET /admin/api/usage/totals request shape, derived from its `drill` breadcrumb state. */
|
||||
export type DrilldownLevel = { kind: 'group' } | { kind: 'user'; group: string } | { kind: 'usermodel'; user: string }
|
||||
|
||||
/**
|
||||
* drilldownLevel derives AttributionDrilldown's current level from its
|
||||
* `drill` breadcrumb state: '' is the top (every group listed), a
|
||||
* `group:{name}` drill shows that group's own users, anything else is
|
||||
* read as a `user:{name}` drill showing that user's own per-model
|
||||
* breakdown — the ONE definition (moved from stores/spend.ts, which used
|
||||
* to duplicate AttributionDrilldown.vue's own identical copy verbatim).
|
||||
*/
|
||||
export function drilldownLevel(drill: string): DrilldownLevel {
|
||||
if (drill === '') return { kind: 'group' }
|
||||
const parsed = parseScope(drill)
|
||||
if (parsed?.kind === 'group') return { kind: 'user', group: parsed.id }
|
||||
return { kind: 'usermodel', user: parsed?.kind === 'user' ? parsed.id : drill }
|
||||
}
|
||||
|
||||
/**
|
||||
* scopeBudgetLimits resolves the configured cost/month LimitsConfig for a
|
||||
* `filters.scope`-shaped string (redesign-plan.md section 3.1:
|
||||
* `scope=all|group:x|user:x|provider:x`) from the already-polled dashboard
|
||||
* overview/usage data — no second network round trip. `'all'` (or any
|
||||
* unscoped/root string parseScope does not recognize a `group:`/`user:`
|
||||
* prefix on) resolves to the FLEET-WIDE sum of every configured group's
|
||||
* own budget (Q2/DECISIONS: lib/kpi.ts's sumGroupBudgetMicros — the ONE
|
||||
* fleet-budget definition, replacing SpendPage.vue's own inline recompute
|
||||
* of the identical sum), `undefined` when that total is 0 ("no budget
|
||||
* set", never a fabricated zero-dollar budget). `group:`/`user:` resolve
|
||||
* to that one group's/user's own configured limits (undefined if the
|
||||
* scope names a group/user this fleet does not actually have); a
|
||||
* `provider:` scope (or any other unrecognized prefix) has no single
|
||||
* group/user limit to report and also resolves to undefined.
|
||||
*/
|
||||
export function scopeBudgetLimits(scope: string, groups: AdminGroupView[], users: AdminUsageEntryView[]): LimitsConfig | undefined {
|
||||
const parsed = parseScope(scope)
|
||||
if (!parsed) {
|
||||
const totalMicros = sumGroupBudgetMicros(groups)
|
||||
return totalMicros > 0 ? { costPerMonthUSD: microsToUsd(totalMicros) } : undefined
|
||||
}
|
||||
if (parsed.kind === 'group') return groups.find((g) => g.name === parsed.id)?.limits
|
||||
if (parsed.kind === 'user') return users.find((u) => u.id === parsed.id)?.limits
|
||||
return undefined
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
// keep their own math out of theirs — vitest's node-environment config
|
||||
// exercises every function here directly, no component mount required.
|
||||
|
||||
import { parseScope } from '@/lib/scope'
|
||||
|
||||
/** One AdminSeriesResponse series entry: a scope id and its bucket-aligned points (types/api.ts: AdminSeriesResponse['series'][number]). */
|
||||
export interface Series {
|
||||
scope: string
|
||||
@@ -86,7 +88,8 @@ export function other(totalPoints: number[], charted: Series[]): number[] {
|
||||
export function sumByProvider(series: Series[]): Series[] {
|
||||
const byProvider = new Map<string, number[]>()
|
||||
for (const s of series) {
|
||||
const id = s.scope.startsWith('model:') ? s.scope.slice('model:'.length) : s.scope
|
||||
const parsed = parseScope(s.scope)
|
||||
const id = parsed?.kind === 'model' ? parsed.id : s.scope
|
||||
const slash = id.indexOf('/')
|
||||
const provider = slash === -1 ? id : id.slice(0, slash)
|
||||
const acc = byProvider.get(provider) ?? new Array<number>(s.points.length).fill(0)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isValidNonNegativeInt,
|
||||
isValidUserName,
|
||||
modelMetaSnippet,
|
||||
parseList,
|
||||
pricingSnippet,
|
||||
userGrantJsonFragment,
|
||||
userJsonLine,
|
||||
@@ -17,6 +18,23 @@ import {
|
||||
validatePersonalGrant,
|
||||
} from './snippets'
|
||||
|
||||
describe('parseList', () => {
|
||||
it('splits on commas and trims whitespace', () => {
|
||||
expect(parseList('anthropic, openai , gx10')).toEqual(['anthropic', 'openai', 'gx10'])
|
||||
})
|
||||
|
||||
it('drops empty entries (blank field, trailing comma, doubled comma)', () => {
|
||||
expect(parseList('')).toEqual([])
|
||||
expect(parseList('anthropic,')).toEqual(['anthropic'])
|
||||
expect(parseList('anthropic,,openai')).toEqual(['anthropic', 'openai'])
|
||||
expect(parseList(' ')).toEqual([])
|
||||
})
|
||||
|
||||
it('de-duplicates, keeping first-seen order', () => {
|
||||
expect(parseList('anthropic, openai, anthropic')).toEqual(['anthropic', 'openai'])
|
||||
})
|
||||
})
|
||||
|
||||
// Golden strings below are checked against the REAL production shapes in
|
||||
// home-cluster/kubernetes/namespaces/traefik/base/llmgateway/middleware.yaml
|
||||
// and users-configmap.yaml (read this session) — structure and field order
|
||||
|
||||
@@ -28,6 +28,20 @@ export const NAME_PATTERN = /^[a-zA-Z0-9._-]+$/
|
||||
export function isValidName(name: string): boolean {
|
||||
return NAME_PATTERN.test(name)
|
||||
}
|
||||
/**
|
||||
* parseList (reuse-audit.md F12) splits a comma-separated form field into
|
||||
* trimmed, non-empty, de-duplicated entries — the ONE shared parser
|
||||
* GrantForm.vue's and UserForm.vue's own two identical copies (providers/
|
||||
* models/mcpServers/agents, extraGroups) each hand-rolled.
|
||||
*/
|
||||
export function parseList(raw: string): string[] {
|
||||
const seen = new Set<string>()
|
||||
for (const part of raw.split(',')) {
|
||||
const trimmed = part.trim()
|
||||
if (trimmed) seen.add(trimmed)
|
||||
}
|
||||
return Array.from(seen)
|
||||
}
|
||||
|
||||
/**
|
||||
* isValidUserName mirrors auth.go's own buildEntry check (~955-975,
|
||||
|
||||
@@ -1,33 +1,11 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { h } from 'vue'
|
||||
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { compactColumn } from '@/lib/columns'
|
||||
import { formatElapsedAgo, formatLatencyMs } from '@/lib/format'
|
||||
import type { AdminTargetHealthView, AdminTargetsResponse, AdminTargetView, AdminTotalsResponse } from '@/types/api'
|
||||
|
||||
/**
|
||||
* numericColumn builds one right-aligned, sortable request-counter
|
||||
* column, rendered via CompactNumber (SI-style, exact value on title/
|
||||
* aria-label — usage-columns.ts's own numericColumn applies the
|
||||
* identical treatment). Mirrors usage-columns.ts's own helper of the
|
||||
* same name and purpose, but without that one's storeDown '?' masking: a
|
||||
* target's counters carry no storeDown flag (limiter.targetUsage,
|
||||
* limits.go — a target scope has no limit to protect from a
|
||||
* misleadingly-confident zero, unlike a limited user/group scope), so
|
||||
* there is nothing to mask here. accessorFn (and therefore sorting)
|
||||
* always reads the true raw number.
|
||||
*/
|
||||
function numericColumn(id: string, header: string, read: (t: AdminTargetView) => number): ColumnDef<AdminTargetView, unknown> {
|
||||
return {
|
||||
id,
|
||||
header,
|
||||
accessorFn: read,
|
||||
meta: { align: 'right' },
|
||||
cell: ({ row }) => h(CompactNumber, { value: read(row.original) }),
|
||||
}
|
||||
}
|
||||
|
||||
// --- target health (feat/target-health) ---
|
||||
//
|
||||
// Mirrors ProviderHealthPanel.vue's own healthBadgeVariant/healthBadgeLabel
|
||||
@@ -181,9 +159,9 @@ export function targetColumns(onSelect?: (target: AdminTargetView) => void): Col
|
||||
)
|
||||
},
|
||||
},
|
||||
numericColumn('reqMin', 'req/min', (t) => t.counters.requestsPerMinute),
|
||||
numericColumn('reqDay', 'req/day', (t) => t.counters.requestsPerDay),
|
||||
numericColumn('reqMonth', 'req/month', (t) => t.counters.requestsPerMonth),
|
||||
compactColumn<AdminTargetView>('reqMin', 'req/min', (t) => t.counters.requestsPerMinute),
|
||||
compactColumn<AdminTargetView>('reqDay', 'req/day', (t) => t.counters.requestsPerDay),
|
||||
compactColumn<AdminTargetView>('reqMonth', 'req/month', (t) => t.counters.requestsPerMonth),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -336,12 +314,6 @@ export function targetCallerColumns(showTarget: boolean): ColumnDef<TargetCaller
|
||||
return h('span', { class: 'font-medium' }, caller || row.original.id)
|
||||
},
|
||||
})
|
||||
columns.push({
|
||||
id: 'requests',
|
||||
header: 'Requests',
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (row) => row.values.req ?? 0,
|
||||
cell: ({ row }) => h(CompactNumber, { value: row.original.values.req ?? 0 }),
|
||||
})
|
||||
columns.push(compactColumn<TargetCallerRow>('requests', 'Requests', (row) => row.values.req ?? 0))
|
||||
return columns
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { BAR_AMBER_THRESHOLD, BAR_RED_THRESHOLD, budgetRatios, ratioTier } from './usage-bars'
|
||||
import { BAR_AMBER_THRESHOLD, BAR_RED_THRESHOLD, budgetRatios, budgetValueText, ratioTier } from './usage-bars'
|
||||
import type { AdminUsageEntryView } from '@/types/api'
|
||||
|
||||
function testEntry(overrides: Partial<AdminUsageEntryView> = {}): AdminUsageEntryView {
|
||||
@@ -121,6 +121,17 @@ describe('budgetRatios', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('budgetValueText', () => {
|
||||
it('formats a cost-kind ratio (id starting "cost") through formatCost, both sides', () => {
|
||||
expect(budgetValueText({ id: 'costMonth', used: 1_500_000, limit: 5_000_000, ratio: 0.3 })).toBe('$1.5000 / $5.0000')
|
||||
})
|
||||
|
||||
it('formats every other kind through formatExactInt, both sides', () => {
|
||||
expect(budgetValueText({ id: 'reqMin', used: 42, limit: 100, ratio: 0.42 })).toBe('42 / 100')
|
||||
expect(budgetValueText({ id: 'tokDay', used: 1_500, limit: 2_000, ratio: 0.75 })).toBe('1,500 / 2,000')
|
||||
})
|
||||
})
|
||||
|
||||
// estimatedRunOutDate was removed (P2 item 14 — it disagreed with
|
||||
// lib/burndown.ts's runOutDate on an already-exceeded budget and on day 1
|
||||
// of the month; UserDetail.vue and BurnDownChart.vue now both call the
|
||||
|
||||
+30
-14
@@ -38,19 +38,21 @@ export interface BudgetRatio {
|
||||
}
|
||||
|
||||
/**
|
||||
* MICROS_PER_USD converts a LimitsConfig cost limit (plain USD, e.g.
|
||||
* `costPerDayUSD: 5`) into the same micro-USD unit AdminUsageEntryView's
|
||||
* own cost fields already use — matching metrics.go's usdToMicros(round)
|
||||
* convention (ground truth, dashboard-plan.md section 0).
|
||||
*
|
||||
* P11 review fix (DRY): this constant used to be declared independently
|
||||
* three times (here, lib/forecast.ts, lib/usage-columns.ts) — this is now
|
||||
* the ONE definition; lib/forecast.ts imports it, and lib/usage-columns.ts
|
||||
* no longer needs its own copy at all (it now reads limits through
|
||||
* budgetRatios below instead of re-deriving them — see numericColumn's own
|
||||
* doc comment there).
|
||||
* usdToMicros / microsToUsd (reuse-audit.md F8): moved to lib/format.ts
|
||||
* (formatCost's own home — an "odd home" here, the audit's words, for
|
||||
* conversions every cost-limit needs), re-exported here so every existing
|
||||
* `from '@/lib/usage-bars'` import site (lib/forecast.ts, lib/kpi.ts, lib/
|
||||
* usage-csv.ts, lib/scope.ts, stores/spend.ts, pages/HomePage.vue,
|
||||
* components/UserDetail.vue) keeps working unchanged. P11 review fix
|
||||
* (DRY) history: the underlying MICROS_PER_USD constant used to be
|
||||
* declared independently three times (here, lib/forecast.ts, lib/usage-
|
||||
* columns.ts) before the first consolidation onto this file; F8 is the
|
||||
* second consolidation, onto lib/format.ts. MICROS_PER_USD itself is not
|
||||
* re-exported here — nothing imports it from this module, only from lib/
|
||||
* format.ts directly.
|
||||
*/
|
||||
export const MICROS_PER_USD = 1_000_000
|
||||
export { usdToMicros, microsToUsd } from '@/lib/format'
|
||||
import { formatCost, formatExactInt, usdToMicros } from '@/lib/format'
|
||||
|
||||
/**
|
||||
* BUDGET_RATIO_LABEL (P10) names each BudgetRatio id in accurate,
|
||||
@@ -122,11 +124,11 @@ export function budgetRatios(entry: AdminUsageEntryView): BudgetRatio[] {
|
||||
// the rounded value, makes both paths agree with Go: a limit that rounds
|
||||
// to 0 micro-USD is skipped, exactly like an unconfigured one.
|
||||
if (limits.costPerDayUSD) {
|
||||
const limitMicros = Math.round(limits.costPerDayUSD * MICROS_PER_USD)
|
||||
const limitMicros = usdToMicros(limits.costPerDayUSD)
|
||||
if (limitMicros > 0) out.push(ratioOf('costDay', entry.costPerDayMicroUsd, limitMicros))
|
||||
}
|
||||
if (limits.costPerMonthUSD) {
|
||||
const limitMicros = Math.round(limits.costPerMonthUSD * MICROS_PER_USD)
|
||||
const limitMicros = usdToMicros(limits.costPerMonthUSD)
|
||||
if (limitMicros > 0) out.push(ratioOf('costMonth', entry.costPerMonthMicroUsd, limitMicros))
|
||||
}
|
||||
return out
|
||||
@@ -137,3 +139,17 @@ export function budgetRatios(entry: AdminUsageEntryView): BudgetRatio[] {
|
||||
// ONE run-out projection in this codebase — it used to disagree with this
|
||||
// one on an already-exceeded budget (null here vs `now` there) and on day
|
||||
// 1 of the month (clamped/overstated here vs null there).
|
||||
|
||||
/**
|
||||
* budgetValueText (reuse-audit.md F7) renders one BudgetRatio's "used /
|
||||
* limit" detail text (UsageBar's aria-valuetext/title) — a cost-kind ratio
|
||||
* (id starting "cost") formats both sides through formatCost (dollars),
|
||||
* every other kind (requests, tokens) through formatExactInt (a plain
|
||||
* thousands-grouped integer) — the ONE definition, replacing
|
||||
* ConsumerDirectory.vue's and UserDetail.vue's own two copies (which had
|
||||
* drifted onto two different signatures for the identical logic).
|
||||
*/
|
||||
export function budgetValueText(ratio: BudgetRatio): string {
|
||||
if (ratio.id.startsWith('cost')) return `${formatCost(ratio.used)} / ${formatCost(ratio.limit)}`
|
||||
return `${formatExactInt(ratio.used)} / ${formatExactInt(ratio.limit)}`
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { h } from 'vue'
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import EntityLink from '@/components/EntityLink.vue'
|
||||
import UsageBar from '@/components/UsageBar.vue'
|
||||
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
|
||||
import { EMPTY_CELL } from '@/lib/columns'
|
||||
import { monthCostProjection, NOT_ENOUGH_DATA, WILL_EXCEED_TITLE } from '@/lib/forecast'
|
||||
import { formatCost, formatExactInt, formatLimits } from '@/lib/format'
|
||||
import { formatLastSeen } from '@/lib/last-seen'
|
||||
import { BUDGET_RATIO_LABEL, budgetRatios } from '@/lib/usage-bars'
|
||||
@@ -115,17 +116,19 @@ function rejDayColumn(): ColumnDef<AdminUsageEntryView, unknown> {
|
||||
* cost for each row, extrapolated from that row's own costPerMonthMicroUsd
|
||||
* — the same math CostForecastCard.vue applies to the Total entry, here
|
||||
* applied per user/group so an operator can see WHICH scope is on track
|
||||
* to blow its own cost/month limit, not just the fleet-wide total.
|
||||
* to blow its own cost/month limit, not just the fleet-wide total. Reuses
|
||||
* lib/forecast.ts's own monthCostProjection (F7's one projection+headroom
|
||||
* pairing) rather than calling projectMonthEnd/headroom separately here —
|
||||
* the same helper ConsumerDirectory.vue's groupCostProjection and lib/
|
||||
* usage-csv.ts's own CSV row call.
|
||||
*
|
||||
* `now` is threaded in from the caller (coordinator brief: "now passed as
|
||||
* prop for determinism") rather than read via `new Date()` inside this
|
||||
* module — usageColumns() takes it as an optional parameter, defaulting
|
||||
* to the real current time for every production call site, so a test can
|
||||
* pin it instead. elapsedFraction is computed ONCE per usageColumns() call
|
||||
* (not per row) since every row projects against the identical point in
|
||||
* the current UTC month.
|
||||
* pin it instead.
|
||||
*/
|
||||
function costMonthProjColumn(elapsedFraction: number): ColumnDef<AdminUsageEntryView, unknown> {
|
||||
function costMonthProjColumn(now: Date): ColumnDef<AdminUsageEntryView, unknown> {
|
||||
return {
|
||||
id: 'costMonthProj',
|
||||
header: 'cost/month (proj.)',
|
||||
@@ -133,19 +136,18 @@ function costMonthProjColumn(elapsedFraction: number): ColumnDef<AdminUsageEntry
|
||||
// Sorting: a not-enough-data-yet row (null projection) has no real
|
||||
// figure to compare — it sorts as if projected to 0, the smallest
|
||||
// possible spend, rather than being excluded from sorting entirely.
|
||||
accessorFn: (entry) => projectMonthEnd(entry.costPerMonthMicroUsd, elapsedFraction) ?? 0,
|
||||
accessorFn: (entry) => monthCostProjection(entry, now).micros ?? 0,
|
||||
cell: ({ row }) => {
|
||||
if (row.original.storeDown) return '?'
|
||||
const projected = projectMonthEnd(row.original.costPerMonthMicroUsd, elapsedFraction)
|
||||
if (projected === null) return h('span', { class: 'text-muted-foreground' }, 'not enough data yet')
|
||||
const result = headroom(projected, row.original.limits?.costPerMonthUSD)
|
||||
const projection = monthCostProjection(row.original, now)
|
||||
if (projection.micros === null) return h('span', { class: 'text-muted-foreground' }, NOT_ENOUGH_DATA)
|
||||
return h(
|
||||
'span',
|
||||
{
|
||||
class: result.willExceed ? 'text-destructive' : undefined,
|
||||
title: result.willExceed ? 'Projected to exceed the configured cost/month limit' : undefined,
|
||||
class: projection.willExceed ? 'text-destructive' : undefined,
|
||||
title: projection.willExceed ? WILL_EXCEED_TITLE : undefined,
|
||||
},
|
||||
formatCost(projected),
|
||||
formatCost(projection.micros),
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -170,7 +172,7 @@ function sourceColumn(sourceValue: (entry: AdminUsageEntryView) => string | unde
|
||||
id: 'source',
|
||||
header: 'Source',
|
||||
accessorFn: (entry) => sourceValue(entry) ?? '',
|
||||
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, sourceValue(row.original) ?? '—'),
|
||||
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, sourceValue(row.original) ?? EMPTY_CELL),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +219,6 @@ export function usageColumns(
|
||||
now: Date = new Date(),
|
||||
sourceValue?: (entry: AdminUsageEntryView) => string | undefined,
|
||||
): ColumnDef<AdminUsageEntryView, unknown>[] {
|
||||
const elapsedFraction = monthProgress(now)
|
||||
const columns: ColumnDef<AdminUsageEntryView, unknown>[] = [
|
||||
{
|
||||
id: 'id',
|
||||
@@ -266,7 +267,7 @@ export function usageColumns(
|
||||
numericColumn('tokOutMonth', 'tokOut/month', (e) => e.tokensOutPerMonth, String, true, 'tokMonth'),
|
||||
numericColumn('costDay', 'cost/day', (e) => e.costPerDayMicroUsd, formatCost, false, 'costDay'),
|
||||
numericColumn('costMonth', 'cost/month', (e) => e.costPerMonthMicroUsd, formatCost, false, 'costMonth'),
|
||||
costMonthProjColumn(elapsedFraction),
|
||||
costMonthProjColumn(now),
|
||||
lastSeenColumn(now),
|
||||
)
|
||||
return columns
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// formula-injection neutralisation) behavior is defined in exactly one
|
||||
// place (that module's own doc comment).
|
||||
import { toCsv } from '@/lib/csv'
|
||||
import { monthProgress, projectMonthEnd } from '@/lib/forecast'
|
||||
import { monthCostProjection, NOT_ENOUGH_DATA } from '@/lib/forecast'
|
||||
import { formatLimits } from '@/lib/format'
|
||||
import { MICROS_PER_USD } from '@/lib/usage-bars'
|
||||
import { microsToUsd } from '@/lib/usage-bars'
|
||||
import type { AdminUsageEntryView } from '@/types/api'
|
||||
|
||||
/** UsageCsvKind picks the second column's label — "Group" for a Users export (each row's own group membership), "Members" for a Groups export (each row's member count) — mirroring usage-columns.ts's own usageColumns(idLabel, secondaryColumnLabel, ...) call sites in UsageTable.vue/ConsumerDirectory.vue exactly. */
|
||||
@@ -30,7 +30,7 @@ const USAGE_CSV_SECONDARY_LABEL: Record<UsageCsvKind, string> = {
|
||||
* 1 micro-USD = $0.000001.
|
||||
*/
|
||||
function csvCostUsd(micros: number): string {
|
||||
return (micros / MICROS_PER_USD).toFixed(6)
|
||||
return microsToUsd(micros).toFixed(6)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +63,11 @@ function csvCostUsd(micros: number): string {
|
||||
* `now` (F7, default the real current time) is threaded in rather than
|
||||
* read via `new Date()` inside this module — the same "caller can pin it
|
||||
* under test" convention costMonthProjColumn/CostForecastCard already
|
||||
* follow.
|
||||
* follow. The projected month-end cost column reuses lib/forecast.ts's
|
||||
* own monthCostProjection (the same helper costMonthProjColumn and
|
||||
* ConsumerDirectory.vue's groupCostProjection call) rather than pairing
|
||||
* projectMonthEnd by hand — this export only reads its `.micros`, never
|
||||
* `.willExceed` (a CSV cell has no styling to apply).
|
||||
*/
|
||||
export function usageCsv(
|
||||
entries: AdminUsageEntryView[],
|
||||
@@ -71,7 +75,6 @@ export function usageCsv(
|
||||
secondaryValue: (entry: AdminUsageEntryView) => string,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
const elapsedFraction = monthProgress(now)
|
||||
const headers = [
|
||||
'Name',
|
||||
USAGE_CSV_SECONDARY_LABEL[kind],
|
||||
@@ -90,7 +93,7 @@ export function usageCsv(
|
||||
]
|
||||
const rows = entries.map((entry) => {
|
||||
const masked = entry.storeDown
|
||||
const projected = masked ? null : projectMonthEnd(entry.costPerMonthMicroUsd, elapsedFraction)
|
||||
const projected = masked ? null : monthCostProjection(entry, now).micros
|
||||
return [
|
||||
entry.id,
|
||||
secondaryValue(entry),
|
||||
@@ -104,7 +107,7 @@ export function usageCsv(
|
||||
masked ? '?' : entry.tokensOutPerMonth,
|
||||
masked ? '?' : csvCostUsd(entry.costPerDayMicroUsd),
|
||||
masked ? '?' : csvCostUsd(entry.costPerMonthMicroUsd),
|
||||
masked ? '?' : projected === null ? 'not enough data yet' : csvCostUsd(projected),
|
||||
masked ? '?' : projected === null ? NOT_ENOUGH_DATA : csvCostUsd(projected),
|
||||
// Never masked — lastSeen is its own SET-based counter family
|
||||
// (redesign-plan.md section 1.2), independent of the INCRBY usage
|
||||
// counters `storeDown` actually guards (see usage-columns.ts's
|
||||
|
||||
@@ -3,18 +3,18 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import KpiTile from '@/components/KpiTile.vue'
|
||||
import LoadStateView from '@/components/LoadStateView.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 { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { EVENT_KIND_LABEL, eventKindVariant } from '@/lib/events-filter'
|
||||
import { AdminApiError, adminFetch, messageOf } from '@/lib/api'
|
||||
import { eventKindVariant, kindLabel } from '@/lib/events-filter'
|
||||
import { formatCompactCount, formatCost, formatTimestamp } from '@/lib/format'
|
||||
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
|
||||
import { headroom, monthProgress, NOT_ENOUGH_DATA, projectMonthEnd } from '@/lib/forecast'
|
||||
import {
|
||||
budgetRatio,
|
||||
budgetTier,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { loadState } from '@/lib/load-state'
|
||||
import { createVisibilityPoller } from '@/lib/polling'
|
||||
import type { VisibilityPoller } from '@/lib/polling'
|
||||
import { formatErrorRatePercent } from '@/lib/provider-rate'
|
||||
import {
|
||||
DEFAULT_TOP_USERS_MODE,
|
||||
parseTopUsersMode,
|
||||
@@ -37,12 +38,13 @@ import {
|
||||
topUsersTotalsUrl,
|
||||
} from '@/lib/top-users'
|
||||
import type { TopUsersMode } from '@/lib/top-users'
|
||||
import { microsToUsd } from '@/lib/usage-bars'
|
||||
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'
|
||||
import type { AdminTotalsResponse, AdminUsageModelsResponse } from '@/types/api'
|
||||
|
||||
/**
|
||||
* HomePage (redesign-plan.md section 3.4) is the redesign's landing
|
||||
@@ -81,10 +83,10 @@ const spendDelta = computed<string>(() => {
|
||||
|
||||
const projectedMicros = computed<number | null>(() => projectMonthEnd(spendMtdMicros.value, monthProgress(new Date())))
|
||||
const projectedHeadroom = computed(() =>
|
||||
headroom(projectedMicros.value, budgetMicros.value > 0 ? budgetMicros.value / 1_000_000 : undefined),
|
||||
headroom(projectedMicros.value, budgetMicros.value > 0 ? microsToUsd(budgetMicros.value) : undefined),
|
||||
)
|
||||
const projectedValue = computed<string>(() =>
|
||||
projectedMicros.value === null ? 'not enough data yet' : formatCost(projectedMicros.value),
|
||||
projectedMicros.value === null ? NOT_ENOUGH_DATA : formatCost(projectedMicros.value),
|
||||
)
|
||||
const projectedTier = computed(() => (projectedHeadroom.value.willExceed ? 'critical' : 'ok'))
|
||||
const projectedDelta = computed<string | undefined>(() => {
|
||||
@@ -101,16 +103,8 @@ const requestsPerMinute = computed<number>(() => dashboard.usage?.total.requests
|
||||
|
||||
const errorRate = computed(() => fleetErrorRate(dashboard.overview?.providers ?? []))
|
||||
const errorTier = computed(() => errorRateTier(errorRate.value.rate))
|
||||
const errorValue = computed<string>(() => {
|
||||
const rate = errorRate.value.rate
|
||||
if (rate === null) return 'no traffic'
|
||||
if (rate <= 0) return '0%'
|
||||
// Ceiling, not the usual floor: a genuinely non-zero error rate must
|
||||
// never round down to a misleadingly clean "0%" (the opposite honesty
|
||||
// direction from a SUCCESS rate, which floors so it never overstates
|
||||
// — see lib/provider-rate.ts's own formatRatePercent doc comment).
|
||||
return `${Math.ceil(rate * 100)}%`
|
||||
})
|
||||
/** errorValue reuses lib/provider-rate.ts's shared formatErrorRatePercent (reuse-audit.md F13) — the Home tile's own "no traffic" null wording is the ONE difference from the Reliability/Models pages' "no data" default, passed via the nullLabel param rather than a second, hand-rolled copy of the same ceiling-percent formatting. */
|
||||
const errorValue = computed<string>(() => formatErrorRatePercent(errorRate.value.rate, 'no traffic'))
|
||||
const errorDelta = computed<string>(() => `${errorRate.value.failures}/${errorRate.value.attempts} attempts in the last minute`)
|
||||
|
||||
// --- Open breakers / warnings / unpriced ---------------------------------
|
||||
@@ -308,7 +302,7 @@ async function fetchTopUsers(): Promise<void> {
|
||||
topUsersRangeTooLarge.value = true
|
||||
topUsersError.value = ''
|
||||
} else {
|
||||
topUsersError.value = err instanceof Error ? err.message : String(err)
|
||||
topUsersError.value = messageOf(err)
|
||||
}
|
||||
} finally {
|
||||
if (requestId === topUsersReqId) topUsersLoading.value = false
|
||||
@@ -334,7 +328,7 @@ async function fetchTopModels(): Promise<void> {
|
||||
topModelsLoaded.value = true
|
||||
} catch (err) {
|
||||
if (requestId !== topModelsReqId) return
|
||||
topModelsError.value = err instanceof Error ? err.message : String(err)
|
||||
topModelsError.value = messageOf(err)
|
||||
} finally {
|
||||
if (requestId === topModelsReqId) topModelsLoading.value = false
|
||||
}
|
||||
@@ -383,11 +377,6 @@ watch(
|
||||
|
||||
// --- Latest 8 events -------------------------------------------------------
|
||||
|
||||
/** kindLabel falls back to the raw kind string for a kind EVENT_KIND_LABEL does not know (a server newer than this webui build) — same forward-compat convention lib/events-columns.ts's own identical helper documents. */
|
||||
function kindLabel(kind: string): string {
|
||||
return EVENT_KIND_LABEL[kind as AdminEventKind] ?? kind
|
||||
}
|
||||
|
||||
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. */
|
||||
@@ -498,10 +487,8 @@ onUnmounted(() => {
|
||||
<CardTitle>Latest events</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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">
|
||||
<LoadStateView :state="eventsCardState" :error="events.error" :on-retry="events.refresh" skeleton="list" :rows="4" :empty="noEventsRecorded" empty-title="No events recorded yet.">
|
||||
<ol class="flex flex-col gap-2">
|
||||
<li
|
||||
v-for="(event, i) in latestEvents"
|
||||
:key="`${event.time}-${event.replica}-${i}`"
|
||||
@@ -514,6 +501,7 @@ onUnmounted(() => {
|
||||
<span class="shrink-0 tabular-nums text-muted-foreground">{{ formatTimestamp(event.time) }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</LoadStateView>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import ChartCard from '@/components/ChartCard.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import EventsView from '@/components/EventsView.vue'
|
||||
import LabeledSelect from '@/components/LabeledSelect.vue'
|
||||
import SkeletonChart from '@/components/SkeletonChart.vue'
|
||||
import SkeletonList from '@/components/SkeletonList.vue'
|
||||
import StatDisabledAlert from '@/components/StatDisabledAlert.vue'
|
||||
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
|
||||
import type { TimeSeriesDataset } from '@/components/TimeSeriesChart.vue'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Alert, AlertDescription } 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 { seriesColor } from '@/lib/chart-palette'
|
||||
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 { scopeId } from '@/lib/scope'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useEventsStore } from '@/stores/events'
|
||||
import { useFiltersStore } from '@/stores/filters'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
import { seriesColor, useReliabilityStore } from '@/stores/reliability'
|
||||
import { useReliabilityStore } from '@/stores/reliability'
|
||||
import type { CountSeries, ProviderRatioSeries } from '@/stores/reliability'
|
||||
|
||||
/**
|
||||
@@ -37,9 +40,6 @@ import type { CountSeries, ProviderRatioSeries } from '@/stores/reliability'
|
||||
* round-trip through the URL hash the same way EntityLink.vue's params do
|
||||
* — nav.goTo on every local change, read back via nav.params on mount.
|
||||
*/
|
||||
/** '' is the "every provider" state (selectedProvider/filterSelected below). Radix/reka-ui Select items cannot use an empty-string value (EventsView.vue's own identical ALL_KINDS doc comment) — ALL_PROVIDERS_OPTION is the Select-only sentinel, mapped to/from '' at the template boundary. */
|
||||
const ALL_PROVIDERS_OPTION = 'all'
|
||||
|
||||
const dashboard = useDashboardStore()
|
||||
const events = useEventsStore()
|
||||
const filters = useFiltersStore()
|
||||
@@ -47,16 +47,16 @@ const nav = useNavStore()
|
||||
const reliability = useReliabilityStore()
|
||||
|
||||
const selectedProvider = computed<string>(() => nav.params.provider ?? '')
|
||||
const providerSelectValue = computed<string>(() => selectedProvider.value || ALL_PROVIDERS_OPTION)
|
||||
/** providerOptions feeds LabeledSelect's own options list — LabeledSelect owns the "All providers" -> '' sentinel mapping itself (reuse-audit.md F11), so this page no longer needs its own ALL_PROVIDERS_OPTION constant. */
|
||||
const providerOptions = computed(() => (dashboard.overview?.providers ?? []).map((p) => ({ value: p.name, label: p.name })))
|
||||
|
||||
function onProviderChange(value: unknown): void {
|
||||
if (typeof value !== 'string') return
|
||||
nav.goTo('reliability', { ...nav.params, provider: value === ALL_PROVIDERS_OPTION ? '' : value })
|
||||
function onProviderChange(value: string): void {
|
||||
nav.goTo('reliability', { ...nav.params, provider: value })
|
||||
}
|
||||
|
||||
/** providerLabel strips the "provider:" scope prefix every reliability series carries — the display label is just the provider name. */
|
||||
/** providerLabel strips the "provider:" scope prefix every reliability series carries (lib/scope.ts's shared scopeId, reuse-audit.md F5) — the display label is just the provider name. */
|
||||
function providerLabel(scope: string): string {
|
||||
return scope.replace(/^provider:/, '')
|
||||
return scopeId(scope)
|
||||
}
|
||||
|
||||
function filterSelected<T extends { scope: string }>(series: T[]): T[] {
|
||||
@@ -261,24 +261,20 @@ onMounted(() => void reliability.fetch())
|
||||
<CardTitle>Reliability</CardTitle>
|
||||
<CardDescription>Provider error rate, failovers, timeouts, rate-limit rejections, and 402 refusals.</CardDescription>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label id="reliability-provider-label" class="text-xs font-medium text-muted-foreground">Provider</label>
|
||||
<Select :model-value="providerSelectValue" @update:model-value="onProviderChange">
|
||||
<SelectTrigger aria-labelledby="reliability-provider-label" class="w-48">
|
||||
<SelectValue placeholder="All providers" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="ALL_PROVIDERS_OPTION">All providers</SelectItem>
|
||||
<SelectItem v-for="p in dashboard.overview?.providers ?? []" :key="p.name" :value="p.name">{{ p.name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<LabeledSelect
|
||||
label="Provider"
|
||||
:model-value="selectedProvider"
|
||||
:options="providerOptions"
|
||||
all-label="All providers"
|
||||
trigger-class="w-48"
|
||||
@update:model-value="onProviderChange"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
<EmptyState v-else-if="!hasProviders" title="No providers configured." />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -299,30 +295,10 @@ onMounted(() => void reliability.fetch())
|
||||
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>
|
||||
<ChartCard title="Error rate by provider" state="skeleton" />
|
||||
<ChartCard title="Selected provider performance" state="skeleton" />
|
||||
<ChartCard title="Timeouts by provider" state="skeleton" />
|
||||
<ChartCard title="Failovers by provider" state="skeleton" />
|
||||
</template>
|
||||
<Card v-else-if="providersState === 'empty'">
|
||||
<CardContent>
|
||||
@@ -333,17 +309,17 @@ onMounted(() => void reliability.fetch())
|
||||
</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>
|
||||
<ChartCard
|
||||
title="Error rate by provider"
|
||||
description="Failed attempts / total attempts per bucket. A gap means no attempts in that bucket."
|
||||
:state="reliabilityState"
|
||||
:error="reliability.error"
|
||||
:on-retry="reliability.fetch"
|
||||
:labels="bucketLabels"
|
||||
:datasets="errorRateDatasets"
|
||||
:value-formatter="errorRatePercent"
|
||||
aria-label="Error rate by provider over time"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -354,16 +330,12 @@ onMounted(() => void reliability.fetch())
|
||||
<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>
|
||||
<StatDisabledAlert
|
||||
v-else-if="!latencyStatsEnabled"
|
||||
title="Latency statistics are off"
|
||||
config-key="admin.stats.latency"
|
||||
:purpose="`to see per-bucket latency for ${selectedProvider}.`"
|
||||
/>
|
||||
<template v-else-if="providerPerfAvailable">
|
||||
<SkeletonChart v-if="providerPerfState === 'skeleton'" />
|
||||
<ErrorState
|
||||
@@ -393,54 +365,50 @@ onMounted(() => void reliability.fetch())
|
||||
</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>
|
||||
<ChartCard
|
||||
title="Timeouts by provider"
|
||||
:state="reliabilityState"
|
||||
:error="reliability.error"
|
||||
:on-retry="reliability.fetch"
|
||||
:labels="bucketLabels"
|
||||
:datasets="timeoutDatasets"
|
||||
aria-label="Timeouts by provider over time"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<ChartCard
|
||||
title="Failovers by provider"
|
||||
:description="noFailoverConfigured ? 'This deployment has no failover chains configured.' : undefined"
|
||||
:state="reliabilityState"
|
||||
:error="reliability.error"
|
||||
:on-retry="reliability.fetch"
|
||||
:labels="bucketLabels"
|
||||
:datasets="failoverDatasets"
|
||||
aria-label="Failovers by provider over time"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rate-limit rejections (429)</CardTitle>
|
||||
<CardDescription>Fleet-wide, every scope combined.</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="rejectionDatasets" ariaLabel="Rate-limit rejections over time, fleet-wide" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ChartCard
|
||||
title="Rate-limit rejections (429)"
|
||||
description="Fleet-wide, every scope combined."
|
||||
:state="reliabilityState"
|
||||
:error="reliability.error"
|
||||
:on-retry="reliability.fetch"
|
||||
:labels="bucketLabels"
|
||||
:datasets="rejectionDatasets"
|
||||
aria-label="Rate-limit rejections over time, fleet-wide"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Unpriced refusals (402)</CardTitle>
|
||||
<CardDescription>Fleet-wide requests refused for having no resolvable price.</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="unpricedDatasets" ariaLabel="Unpriced refusals (402) over time, fleet-wide" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ChartCard
|
||||
title="Unpriced refusals (402)"
|
||||
description="Fleet-wide requests refused for having no resolvable price."
|
||||
:state="reliabilityState"
|
||||
:error="reliability.error"
|
||||
:on-retry="reliability.fetch"
|
||||
:labels="bucketLabels"
|
||||
:datasets="unpricedDatasets"
|
||||
aria-label="Unpriced refusals (402) over time, fleet-wide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EventsView />
|
||||
|
||||
@@ -3,19 +3,19 @@ import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import AttributionDrilldown from '@/components/AttributionDrilldown.vue'
|
||||
import BurnDownChart from '@/components/BurnDownChart.vue'
|
||||
import ChartCard from '@/components/ChartCard.vue'
|
||||
import CostAvoidedCard from '@/components/CostAvoidedCard.vue'
|
||||
import CostForecastCard from '@/components/CostForecastCard.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import LabeledSelect from '@/components/LabeledSelect.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'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useNow } from '@/composables/useNow'
|
||||
import { cacheSavingsStatus } from '@/lib/cache-savings'
|
||||
import { BREAKDOWN_PALETTE, seriesColor } from '@/lib/chart-palette'
|
||||
import { formatBucketLabel, formatCompactCount, formatCost } from '@/lib/format'
|
||||
import { loadState } from '@/lib/load-state'
|
||||
import { parseScope, scopeBudgetLimits, scopeId } from '@/lib/scope'
|
||||
import { useCatalogStore } from '@/stores/catalog'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useFiltersStore } from '@/stores/filters'
|
||||
@@ -32,6 +32,7 @@ import type { SpendBy } from '@/stores/spend'
|
||||
import type { HistoryMetric, LimitsConfig } from '@/types/api'
|
||||
|
||||
const SPEND_BY_LABEL: Record<SpendBy, string> = { model: 'By model', provider: 'By provider', group: 'By group' }
|
||||
const metricOptions = SPEND_METRICS.map((m) => ({ value: m, label: SPEND_METRIC_LABEL[m] }))
|
||||
|
||||
const nav = useNavStore()
|
||||
const filters = useFiltersStore()
|
||||
@@ -105,43 +106,9 @@ onUnmounted(() => {
|
||||
|
||||
// --- breakdown chart ---
|
||||
|
||||
/**
|
||||
* BREAKDOWN_PALETTE cycles a colorblind-safe (Okabe & Ito, 2008) 8-color
|
||||
* qualitative set across the breakdown chart's per-model/provider/group
|
||||
* series. The first four reuse main.css's own existing --chart-* tokens
|
||||
* (already that same palette's requests/tokens-in/tokens-out/cost
|
||||
* members); the remaining four (--chart-series-a..d) extend it, defined
|
||||
* alongside them in assets/main.css — main.css's original four tokens
|
||||
* were sized for four FIXED semantic metrics, not an open-ended
|
||||
* breakdown series count, so this page needed four more, but every chart
|
||||
* color token lives in the one design-tokens file (Tailwind-only rule),
|
||||
* never a component-local <style> block. Beyond 8 series (e.g. a fleet
|
||||
* with more than 8 configured groups) colors repeat — an accepted
|
||||
* qualitative-palette limit, not a bug: legend labels remain the
|
||||
* disambiguator past that point, the same tradeoff any categorical chart
|
||||
* palette makes once it runs out of distinguishable hues.
|
||||
*/
|
||||
const BREAKDOWN_PALETTE = [
|
||||
'--chart-requests',
|
||||
'--chart-tokens-in',
|
||||
'--chart-tokens-out',
|
||||
'--chart-cost',
|
||||
'--chart-series-a',
|
||||
'--chart-series-b',
|
||||
'--chart-series-c',
|
||||
'--chart-series-d',
|
||||
]
|
||||
|
||||
function paletteColor(index: number): string {
|
||||
return BREAKDOWN_PALETTE[index % BREAKDOWN_PALETTE.length]!
|
||||
}
|
||||
|
||||
/** breakdownLabel strips a series' own scope-kind prefix ("model:"/"group:") for the chart legend — sumByProvider's own output already has no prefix (lib/series.ts). */
|
||||
/** breakdownLabel strips a series' own scope-kind prefix ("model:"/"group:"/"provider:") for the chart legend (lib/scope.ts's shared scopeId, reuse-audit.md F5) — sumByProvider's own output already has no prefix (lib/series.ts). */
|
||||
function breakdownLabel(scope: string): string {
|
||||
if (scope.startsWith('model:')) return scope.slice('model:'.length)
|
||||
if (scope.startsWith('group:')) return scope.slice('group:'.length)
|
||||
if (scope.startsWith('provider:')) return scope.slice('provider:'.length)
|
||||
return scope
|
||||
return scopeId(scope)
|
||||
}
|
||||
|
||||
// The breakdown chart's own window (filters.window) decides the bucket
|
||||
@@ -153,7 +120,7 @@ const breakdownDatasets = computed<TimeSeriesDataset[]>(() => {
|
||||
const charted: TimeSeriesDataset[] = spend.breakdown.map((s, i) => ({
|
||||
label: breakdownLabel(s.scope),
|
||||
data: s.points,
|
||||
color: paletteColor(i),
|
||||
color: seriesColor(i, BREAKDOWN_PALETTE),
|
||||
}))
|
||||
if (spend.otherPoints) charted.push({ label: 'Other', data: spend.otherPoints, color: '--muted-foreground' })
|
||||
return charted
|
||||
@@ -173,38 +140,23 @@ const totalInRange = computed<number>(() => spend.totalPoints.reduce((a, b) => a
|
||||
// --- forecast / burn-down scope derivation (Q2/DECISIONS: fleet budget = sum of group costPerMonthUSD) ---
|
||||
|
||||
const scopeMtdMicros = computed<number>(() => {
|
||||
if (filters.scope === 'all') return dashboard.usage?.total.costPerMonthMicroUsd ?? 0
|
||||
if (filters.scope.startsWith('group:')) {
|
||||
const name = filters.scope.slice('group:'.length)
|
||||
return dashboard.usage?.groups.find((g) => g.id === name)?.costPerMonthMicroUsd ?? 0
|
||||
}
|
||||
if (filters.scope.startsWith('user:')) {
|
||||
const name = filters.scope.slice('user:'.length)
|
||||
return dashboard.usage?.users.find((u) => u.id === name)?.costPerMonthMicroUsd ?? 0
|
||||
}
|
||||
const parsed = parseScope(filters.scope)
|
||||
if (!parsed) return dashboard.usage?.total.costPerMonthMicroUsd ?? 0
|
||||
if (parsed.kind === 'group') return dashboard.usage?.groups.find((g) => g.id === parsed.id)?.costPerMonthMicroUsd ?? 0
|
||||
if (parsed.kind === 'user') return dashboard.usage?.users.find((u) => u.id === parsed.id)?.costPerMonthMicroUsd ?? 0
|
||||
return 0
|
||||
})
|
||||
|
||||
const scopeLimits = computed<LimitsConfig | undefined>(() => {
|
||||
if (filters.scope === 'all') {
|
||||
const groups = dashboard.overview?.groups ?? []
|
||||
const budgeted = groups.filter((g) => (g.limits?.costPerMonthUSD ?? 0) > 0)
|
||||
if (budgeted.length === 0) return undefined
|
||||
const totalUsd = budgeted.reduce((sum, g) => sum + (g.limits?.costPerMonthUSD ?? 0), 0)
|
||||
return { costPerMonthUSD: totalUsd }
|
||||
}
|
||||
if (filters.scope.startsWith('group:')) {
|
||||
const name = filters.scope.slice('group:'.length)
|
||||
return dashboard.overview?.groups.find((g) => g.name === name)?.limits
|
||||
}
|
||||
if (filters.scope.startsWith('user:')) {
|
||||
const name = filters.scope.slice('user:'.length)
|
||||
return dashboard.usage?.users.find((u) => u.id === name)?.limits
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
/** scopeLimits resolves the current global scope's own cost/month LimitsConfig via lib/scope.ts's shared scopeBudgetLimits (reuse-audit.md F5) — the fleet-wide sum-of-group-budgets 'all' case and the per-group/user lookup both used to be reimplemented here, disagreeing in spots with stores/spend.ts's own third copy. */
|
||||
const scopeLimits = computed<LimitsConfig | undefined>(() =>
|
||||
scopeBudgetLimits(filters.scope, dashboard.overview?.groups ?? [], dashboard.usage?.users ?? []),
|
||||
)
|
||||
|
||||
const forecastAvailable = computed<boolean>(() => filters.scope === 'all' || filters.scope.startsWith('group:') || filters.scope.startsWith('user:'))
|
||||
/** forecastAvailable is true for every scope scopeLimits/scopeMtdMicros can resolve a figure for — 'all', a group, or a user; false only for a provider scope, which has no fleet-relative budget concept. */
|
||||
const forecastAvailable = computed<boolean>(() => {
|
||||
const parsed = parseScope(filters.scope)
|
||||
return !parsed || parsed.kind === 'group' || parsed.kind === 'user'
|
||||
})
|
||||
|
||||
// --- attribution drilldown gating ---
|
||||
|
||||
@@ -263,17 +215,7 @@ function retryPricingHealthTable(): Promise<void> {
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label id="spend-metric-label" class="text-xs font-medium text-muted-foreground">Metric</label>
|
||||
<Select :model-value="metric" @update:model-value="onMetricUpdate">
|
||||
<SelectTrigger aria-labelledby="spend-metric-label" class="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="m in SPEND_METRICS" :key="m" :value="m">{{ SPEND_METRIC_LABEL[m] }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<LabeledSelect label="Metric" :model-value="metric" :options="metricOptions" trigger-class="w-40" @update:model-value="onMetricUpdate" />
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-xs font-medium text-muted-foreground">{{ SPEND_METRIC_LABEL[metric] }} in range</p>
|
||||
@@ -312,16 +254,17 @@ function retryPricingHealthTable(): Promise<void> {
|
||||
{{ spend.comparisonUnavailableReason }}
|
||||
</p>
|
||||
|
||||
<SkeletonChart v-if="breakdownState === 'skeleton'" />
|
||||
<ErrorState v-else-if="breakdownState === 'error'" :message="spend.breakdownError" :on-retry="spend.refresh" />
|
||||
<TimeSeriesChart
|
||||
v-else
|
||||
<ChartCard
|
||||
bare
|
||||
:state="breakdownState"
|
||||
:error="spend.breakdownError"
|
||||
:on-retry="spend.refresh"
|
||||
:labels="breakdownLabels"
|
||||
:datasets="breakdownDatasets"
|
||||
:comparison-datasets="comparisonDatasets"
|
||||
stacked
|
||||
:value-formatter="valueFormatter"
|
||||
:ariaLabel="`${SPEND_METRIC_LABEL[metric]} breakdown ${SPEND_BY_LABEL[by].toLowerCase()} over time`"
|
||||
:aria-label="`${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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { AdminCatalogModel, AdminCatalogResponse } from '@/types/api'
|
||||
|
||||
@@ -86,10 +86,10 @@ export const useCatalogStore = defineStore('catalog', {
|
||||
this.error = ''
|
||||
} catch (err) {
|
||||
if (requestId !== this.reqId) return
|
||||
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) {
|
||||
if (isAuthRejection(err)) {
|
||||
return
|
||||
}
|
||||
this.error = err instanceof Error ? err.message : String(err)
|
||||
this.error = messageOf(err)
|
||||
} finally {
|
||||
if (requestId === this.reqId) this.loading = false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { AdminConfigResponse } from '@/types/api'
|
||||
|
||||
@@ -29,8 +29,8 @@ export const useConfigStore = defineStore('config', {
|
||||
this.data = await adminFetch<AdminConfigResponse>('/admin/api/config')
|
||||
this.error = ''
|
||||
} catch (err) {
|
||||
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
|
||||
this.error = err instanceof Error ? err.message : String(err)
|
||||
if (isAuthRejection(err)) return
|
||||
this.error = messageOf(err)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { AdminApiError, adminFetch, isAuthRejection, messageOf } from '@/lib/api'
|
||||
import { dayOrMonthWindow, seriesUrl, totalsUrl } from '@/lib/range'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { AdminConsumersResponse, AdminSeriesResponse, AdminTotalsResponse, HistoryWindow } from '@/types/api'
|
||||
@@ -63,7 +63,7 @@ function emptyDetail(): UserDetailState {
|
||||
/** 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)
|
||||
return messageOf(result.reason)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,8 +102,8 @@ export const useConsumersStore = defineStore('consumers', {
|
||||
this.error = ''
|
||||
this.fetchedAt = Date.now()
|
||||
} catch (err) {
|
||||
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
|
||||
this.error = err instanceof Error ? err.message : String(err)
|
||||
if (isAuthRejection(err)) return
|
||||
this.error = messageOf(err)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
@@ -184,9 +184,7 @@ export const useConsumersStore = defineStore('consumers', {
|
||||
// superseded this one (latest-request-wins) — drop this result.
|
||||
if (this.detail[userId]?.reqId !== requestId) return
|
||||
|
||||
const authRejected = [reqResult, costResult, modelResult].some(
|
||||
(r) => r.status === 'rejected' && r.reason instanceof AdminApiError && (r.reason.status === 401 || r.reason.status === 403),
|
||||
)
|
||||
const authRejected = [reqResult, costResult, modelResult].some((r) => r.status === 'rejected' && isAuthRejection(r.reason))
|
||||
if (authRejected) return
|
||||
|
||||
const modelTotalsUnavailable =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
|
||||
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastsStore } from '@/stores/toasts'
|
||||
@@ -98,18 +98,14 @@ export const useDashboardStore = defineStore('dashboard', {
|
||||
// A 401/403 on ANY section already rejected the key (lib/api.ts) —
|
||||
// the AuthGate takes over the whole view in that case, matching the
|
||||
// pre-existing single-Promise.all behavior; nothing left to report.
|
||||
const authRejected = failures.some(
|
||||
({ reason }) => reason instanceof AdminApiError && (reason.status === 401 || reason.status === 403),
|
||||
)
|
||||
const authRejected = failures.some(({ reason }) => isAuthRejection(reason))
|
||||
if (authRejected) return
|
||||
|
||||
// A partial failure still advances lastUpdated: at least one
|
||||
// section genuinely has fresh data, even though `error` (below)
|
||||
// still surfaces that something is degraded.
|
||||
if (failures.length < 3) this.lastUpdated = new Date()
|
||||
this.error = failures
|
||||
.map(({ label, reason }) => `${label}: ${reason instanceof Error ? reason.message : String(reason)}`)
|
||||
.join('; ')
|
||||
this.error = failures.map(({ label, reason }) => `${label}: ${messageOf(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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
|
||||
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastsStore } from '@/stores/toasts'
|
||||
@@ -87,8 +87,8 @@ export const useEventsStore = defineStore('events', {
|
||||
// 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)
|
||||
if (isAuthRejection(err)) return
|
||||
this.error = messageOf(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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
|
||||
import { buildModelCatalogRows } from '@/lib/model-table-columns'
|
||||
import type { ModelCatalogRow } from '@/lib/model-table-columns'
|
||||
import { useCatalogStore } from '@/stores/catalog'
|
||||
@@ -73,8 +73,8 @@ export const useModelsStore = defineStore('models', {
|
||||
this.error = ''
|
||||
} catch (err) {
|
||||
if (requestId !== this.reqId) return
|
||||
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
|
||||
this.error = err instanceof Error ? err.message : String(err)
|
||||
if (isAuthRejection(err)) return
|
||||
this.error = messageOf(err)
|
||||
} finally {
|
||||
if (requestId === this.reqId) this.loading = false
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// reliability.spec.ts covers the pure helpers (hourOrDayWindow,
|
||||
// errorRateSeries, seriesColor) directly, plus useReliabilityStore.fetch's
|
||||
// errorRateSeries) directly, plus useReliabilityStore.fetch's
|
||||
// provider-scope-list-from-dashboard-overview wiring and its zero-providers
|
||||
// short-circuit — mirroring dashboard.spec.ts's own mocked-adminFetch,
|
||||
// Pinia-under-vitest approach (that file's own doc comment).
|
||||
// Pinia-under-vitest approach (that file's own doc comment). seriesColor/
|
||||
// CHART_SERIES_COLORS live in, and are covered by, lib/chart-palette.spec.ts
|
||||
// — this store no longer re-exports them (reuse-audit.md F13).
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -13,7 +15,7 @@ vi.mock('@/lib/api', async () => {
|
||||
})
|
||||
|
||||
import { adminFetch } from '@/lib/api'
|
||||
import { errorRateSeries, hourOrDayWindow, RELIABILITY_SERIES_COLORS, seriesColor, useReliabilityStore } from '@/stores/reliability'
|
||||
import { errorRateSeries, hourOrDayWindow, useReliabilityStore } from '@/stores/reliability'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import type { AdminOverviewResponse, AdminPerfResponse, AdminPerfRow, AdminSeriesResponse } from '@/types/api'
|
||||
@@ -67,14 +69,6 @@ describe('errorRateSeries', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('seriesColor', () => {
|
||||
it('cycles through RELIABILITY_SERIES_COLORS by index', () => {
|
||||
expect(seriesColor(0)).toBe(RELIABILITY_SERIES_COLORS[0])
|
||||
expect(seriesColor(RELIABILITY_SERIES_COLORS.length)).toBe(RELIABILITY_SERIES_COLORS[0])
|
||||
expect(seriesColor(RELIABILITY_SERIES_COLORS.length + 1)).toBe(RELIABILITY_SERIES_COLORS[1])
|
||||
})
|
||||
})
|
||||
|
||||
const emptyOverview: AdminOverviewResponse = {
|
||||
version: '1',
|
||||
replica: 'pod-a',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { adminFetch, isAuthRejection, messageOf } from '@/lib/api'
|
||||
import { seriesUrl } from '@/lib/range'
|
||||
import { ratioSeries } from '@/lib/series'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
@@ -53,21 +53,6 @@ export function errorRateSeries(attempts: AdminSeriesResponse, fails: AdminSerie
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* RELIABILITY_SERIES_COLORS cycles through main.css's four existing
|
||||
* --chart-* tokens (assets/main.css is owned by WP-E — this store does not
|
||||
* add a fifth) to color the per-provider error-rate/timeout/failover
|
||||
* overlay charts: with more than four configured providers, colors repeat
|
||||
* — a known, documented limitation rather than a silent one, since adding
|
||||
* a genuinely distinct fifth hue is out of this work package's file scope.
|
||||
*/
|
||||
export const RELIABILITY_SERIES_COLORS = ['--chart-requests', '--chart-tokens-in', '--chart-tokens-out', '--chart-cost'] as const
|
||||
|
||||
/** seriesColor picks one of RELIABILITY_SERIES_COLORS by index, cycling. */
|
||||
export function seriesColor(index: number): string {
|
||||
return RELIABILITY_SERIES_COLORS[index % RELIABILITY_SERIES_COLORS.length]
|
||||
}
|
||||
|
||||
/** One plain (non-ratio) metric series, e.g. failovers/timeouts per provider, or the fleet-wide rej/r402 total. */
|
||||
export interface CountSeries {
|
||||
scope: string
|
||||
@@ -179,8 +164,8 @@ export const useReliabilityStore = defineStore('reliability', {
|
||||
this.error = ''
|
||||
} catch (err) {
|
||||
if (requestId !== this.reqId) return
|
||||
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
|
||||
this.error = err instanceof Error ? err.message : String(err)
|
||||
if (isAuthRejection(err)) return
|
||||
this.error = messageOf(err)
|
||||
} finally {
|
||||
if (requestId === this.reqId) this.loading = false
|
||||
}
|
||||
@@ -232,8 +217,8 @@ export const useReliabilityStore = defineStore('reliability', {
|
||||
this.providerPerfError = ''
|
||||
} catch (err) {
|
||||
if (requestId !== this.providerPerfReqId) return
|
||||
if (err instanceof AdminApiError && (err.status === 401 || err.status === 403)) return
|
||||
this.providerPerfError = err instanceof Error ? err.message : String(err)
|
||||
if (isAuthRejection(err)) return
|
||||
this.providerPerfError = messageOf(err)
|
||||
} finally {
|
||||
if (requestId === this.providerPerfReqId) this.providerPerfLoading = false
|
||||
}
|
||||
|
||||
+13
-33
@@ -1,12 +1,13 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { AdminApiError, adminFetch } from '@/lib/api'
|
||||
import { AdminApiError, adminFetch, isAuthRejection, messageOf } from '@/lib/api'
|
||||
import { currentMonthDaySpan } from '@/lib/burndown'
|
||||
import { sumGroupBudgetMicros } from '@/lib/kpi'
|
||||
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
|
||||
import { dayOrMonthWindow, seriesUrl, totalsUrl } from '@/lib/range'
|
||||
import type { TotalsUrlParams } from '@/lib/range'
|
||||
import { drilldownLevel, scopeBudgetLimits } from '@/lib/scope'
|
||||
import { other, stackTopN, sumByProvider, type Series } from '@/lib/series'
|
||||
import { usdToMicros } from '@/lib/usage-bars'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useFiltersStore } from '@/stores/filters'
|
||||
@@ -618,13 +619,6 @@ export const useSpendStore = defineStore('spend', {
|
||||
},
|
||||
})
|
||||
|
||||
/** drilldownLevel derives AttributionDrilldown's current GET /admin/api/usage/totals request shape from the `drill` breadcrumb state (see useSpendStore's own state doc comment on `drill`). */
|
||||
function drilldownLevel(drill: string): { kind: 'group' } | { kind: 'user'; group: string } | { kind: 'usermodel'; user: string } {
|
||||
if (drill === '') return { kind: 'group' }
|
||||
if (drill.startsWith('group:')) return { kind: 'user', group: drill.slice('group:'.length) }
|
||||
return { kind: 'usermodel', user: drill.slice('user:'.length) }
|
||||
}
|
||||
|
||||
/** burndownScopeParam maps the global scope filter to the /usage/series scope this store's burn-down line requests — null for a provider scope, which has no cost series and no budget concept (see useSpendStore's own doc comment). */
|
||||
function burndownScopeParam(filtersScope: string): string | null {
|
||||
if (filtersScope === 'all') return 'total'
|
||||
@@ -634,32 +628,18 @@ function burndownScopeParam(filtersScope: string): string | null {
|
||||
|
||||
/**
|
||||
* burndownBudgetMicros resolves the configured cost/month budget (micro-
|
||||
* USD) for the burn-down line's own scope: the fleet-wide sum-of-group
|
||||
* budgets (Q2/DECISIONS: lib/kpi.ts's sumGroupBudgetMicros) for `total`,
|
||||
* or that one group's/user's own configured costPerMonthUSD limit
|
||||
* otherwise — read from stores/dashboard.ts's already-polled overview/
|
||||
* usage data rather than a second network round trip.
|
||||
* USD) for the burn-down line's own scope, via lib/scope.ts's shared
|
||||
* scopeBudgetLimits (reuse-audit.md F5 — the fleet-wide sum-of-group-
|
||||
* budgets 'total' case and the per-group/user lookup both used to be
|
||||
* reimplemented here a third time) — read from stores/dashboard.ts's
|
||||
* already-polled overview/usage data rather than a second network round
|
||||
* trip. `burndownScopeParam`'s own 'total' sentinel is scopeBudgetLimits'
|
||||
* "unscoped root" case (same as filters.scope's 'all'); a provider scope
|
||||
* never reaches here (burndownScopeParam itself returns null for one).
|
||||
*/
|
||||
function burndownBudgetMicros(scope: string): number | null {
|
||||
const dashboard = useDashboardStore()
|
||||
if (scope === 'total') {
|
||||
const total = sumGroupBudgetMicros(dashboard.overview?.groups ?? [])
|
||||
return total > 0 ? total : null
|
||||
}
|
||||
if (scope.startsWith('group:')) {
|
||||
const name = scope.slice('group:'.length)
|
||||
const usd = dashboard.overview?.groups.find((g) => g.name === name)?.limits?.costPerMonthUSD
|
||||
return usd && usd > 0 ? Math.round(usd * 1_000_000) : null
|
||||
}
|
||||
const name = scope.slice('user:'.length)
|
||||
const usd = dashboard.usage?.users.find((u) => u.id === name)?.limits?.costPerMonthUSD
|
||||
return usd && usd > 0 ? Math.round(usd * 1_000_000) : null
|
||||
const usd = scopeBudgetLimits(scope, dashboard.overview?.groups ?? [], dashboard.usage?.users ?? [])?.costPerMonthUSD
|
||||
return usd && usd > 0 ? usdToMicros(usd) : null
|
||||
}
|
||||
|
||||
function isAuthRejection(err: unknown): boolean {
|
||||
return err instanceof AdminApiError && (err.status === 401 || err.status === 403)
|
||||
}
|
||||
|
||||
function messageOf(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user