mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
feat(webui): redesign the admin dashboard around admin tasks
A sidebar replaces the flat tabs with seven pages, and one filter bar sets the time range, comparison period and scope for all of them. - Home: spend against budget, forecast, request rate, error rate, open breakers, top users and models, recent events and warnings. - Spend: spend over time with comparison, budget burn-down, an attribution drill-down from group to user to model, pricing health and cost avoided by free models. - Consumers: a directory with last seen and limit bars, an access matrix, and a detail page per user. - Models: the catalog with billing prices, fleet latency and error rates, and provider health. - Reliability: error, timeout, failover, 429 and 402 trends, events, and latency per provider. - Config: the redacted config and a change helper that emits YAML or users.json snippets to paste into git. - MCP & Agents: callers per target. Page state lives in the URL hash, and old tab links map to the new pages. The replaced views are removed. admin_assets_gen.go is regenerated. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { faCheck, faTriangleExclamation, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { buildAccessMatrix } from '@/lib/access-matrix'
|
||||
import type { AccessCell } from '@/lib/access-matrix'
|
||||
import { useConsumersStore } from '@/stores/consumers'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
|
||||
/**
|
||||
* AccessMatrix (redesign-plan.md section 3.4) is the Consumers page's
|
||||
* `view=matrix` alternative to ConsumerDirectory: rows are groups, columns
|
||||
* are every configured provider (allowed check + "n/m models", from GET
|
||||
* /admin/api/consumers' AllowedProviders/modelAccess) and every configured
|
||||
* MCP server/agent target (from GET /admin/api/targets' own `access`
|
||||
* list). Pure data shaping lives in lib/access-matrix.ts; this component
|
||||
* only fetches, lays out columns, and renders.
|
||||
*/
|
||||
const consumers = useConsumersStore()
|
||||
const dashboard = useDashboardStore()
|
||||
|
||||
onMounted(() => {
|
||||
void consumers.ensureConsumers()
|
||||
})
|
||||
|
||||
const providerNames = computed(() => (dashboard.overview?.providers ?? []).map((p) => p.name))
|
||||
const mcpServers = computed(() => dashboard.targets?.mcpServers ?? [])
|
||||
const agents = computed(() => dashboard.targets?.agents ?? [])
|
||||
|
||||
/** MatrixColumn flattens the three heterogeneous column groups (providers, MCP servers, agents) into one ordered list — a single v-for renders every header AND every cell, rather than three near-identical template blocks. */
|
||||
interface MatrixColumn {
|
||||
key: string
|
||||
label: string
|
||||
section: 'providers' | 'mcpServers' | 'agents'
|
||||
}
|
||||
|
||||
const columns = computed<MatrixColumn[]>(() => [
|
||||
...providerNames.value.map((name): MatrixColumn => ({ key: name, label: name, section: 'providers' })),
|
||||
...mcpServers.value.map((t): MatrixColumn => ({ key: t.name, label: `${t.name} (MCP)`, section: 'mcpServers' })),
|
||||
...agents.value.map((t): MatrixColumn => ({ key: t.name, label: `${t.name} (agent)`, section: 'agents' })),
|
||||
])
|
||||
|
||||
const rows = computed(() => buildAccessMatrix(consumers.data?.groups ?? [], providerNames.value, mcpServers.value, agents.value))
|
||||
|
||||
const EMPTY_CELL: AccessCell = { allowed: false }
|
||||
|
||||
function cellFor(row: (typeof rows.value)[number], column: MatrixColumn): AccessCell {
|
||||
return row[column.section][column.key] ?? EMPTY_CELL
|
||||
}
|
||||
|
||||
/**
|
||||
* cellSrText is the ONLY accessible text a screen reader announces for
|
||||
* one cell — the visible icon is aria-hidden and the visible detail span
|
||||
* is aria-hidden too (P3 item 18: it used to be read TWICE, once as
|
||||
* plain visible text and again inside this string). A zero-model cell
|
||||
* (`zero`) gets its own wording rather than the plain "Allowed, 0/5
|
||||
* models" reading, which sounds identical to "Allowed, 5/5 models" to a
|
||||
* screen reader user skimming past the number.
|
||||
*/
|
||||
function cellSrText(cell: AccessCell): string {
|
||||
if (!cell.allowed) return 'Not allowed'
|
||||
if (cell.zero) return `Allowed, but ${cell.detail} reachable — the model grant matches none of this provider's models`
|
||||
return cell.detail ? `Allowed, ${cell.detail}` : 'Allowed'
|
||||
}
|
||||
|
||||
/** cellIcon picks the FontAwesome icon for a cell's three visual states: not allowed (X), allowed-but-zero-usable-models (warning triangle — P3 item 18: this used to render an identical green check to a genuinely unrestricted cell), and ordinarily allowed (check). */
|
||||
function cellIcon(cell: AccessCell) {
|
||||
if (!cell.allowed) return faXmark
|
||||
if (cell.zero) return faTriangleExclamation
|
||||
return faCheck
|
||||
}
|
||||
|
||||
/** cellIconClass colors cellIcon's three states distinctly — not-allowed and zero-but-allowed must never share the "everything is fine" green. */
|
||||
function cellIconClass(cell: AccessCell): string {
|
||||
if (!cell.allowed) return 'text-muted-foreground/40'
|
||||
if (cell.zero) return 'text-status-warn'
|
||||
return 'text-chart-requests'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Access matrix</CardTitle>
|
||||
<CardDescription>Which groups can reach which providers, MCP servers, and agents.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent v-if="consumers.error" class="text-sm text-destructive">{{ consumers.error }}</CardContent>
|
||||
<CardContent v-else-if="rows.length === 0" class="py-6 text-center text-sm text-muted-foreground">
|
||||
{{ consumers.loading ? 'loading…' : 'no groups configured' }}
|
||||
</CardContent>
|
||||
<CardContent v-else class="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Group</TableHead>
|
||||
<TableHead v-for="column in columns" :key="column.key + column.section">{{ column.label }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="row in rows" :key="row.group">
|
||||
<TableHead scope="row" class="font-medium">{{ row.group }}</TableHead>
|
||||
<TableCell v-for="column in columns" :key="column.key + column.section" class="text-center" :title="cellFor(row, column).detail">
|
||||
<FontAwesomeIcon :icon="cellIcon(cellFor(row, column))" :class="cellIconClass(cellFor(row, column))" class="size-3.5" aria-hidden="true" />
|
||||
<span v-if="cellFor(row, column).detail" aria-hidden="true" class="ml-1 text-xs text-muted-foreground">{{ cellFor(row, column).detail }}</span>
|
||||
<span class="sr-only">{{ cellSrText(cellFor(row, column)) }}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import { faChevronRight } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { computed, h, ref } from 'vue'
|
||||
|
||||
import ClampedRangeNotice from '@/components/ClampedRangeNotice.vue'
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import EntityLink from '@/components/EntityLink.vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatCost } from '@/lib/format'
|
||||
import { SPEND_METRIC_LABEL } from '@/stores/spend'
|
||||
import type { AdminTotalsResponse, HistoryMetric, HistoryWindow } from '@/types/api'
|
||||
|
||||
/** DrilldownRow is one GET /admin/api/usage/totals row (types/api.ts's AdminTotalsResponse.rows entry). */
|
||||
type DrilldownRow = AdminTotalsResponse['rows'][number]
|
||||
|
||||
/**
|
||||
* AttributionDrilldown (redesign-plan.md section 3.4) descends group ->
|
||||
* users (GET /admin/api/usage/totals?kind=user&group=) -> models (kind=
|
||||
* usermodel, only when AdminFeaturesView.userModelStats — the config's
|
||||
* admin.stats.userModel), breadcrumbs tracked in the `drill` page param
|
||||
* (section 3.1). This component owns no fetch itself — CONTROLLED, like
|
||||
* CostAvoidedCard.vue's own `ref` prop — stores/spend.ts's fetchDrilldown
|
||||
* reads `drill` and supplies `rows`/`metrics`/`disabled`; every click here
|
||||
* only emits `update:drill`, letting SpendPage.vue keep it in nav.params
|
||||
* (so a reload or shared link restores the same drilldown position).
|
||||
*
|
||||
* `userModelStatsEnabled` gates whether a USER row is clickable at all
|
||||
* (redesign-plan.md's own "only when features.userModelStats, else hint
|
||||
* naming the config key") — checked client-side from the already-polled
|
||||
* overview response, before ever firing a request that would only come
|
||||
* back 404. `disabled` (from the store, a REAL 404) is the fallback for
|
||||
* the deep-link case: a reader who lands directly on `drill=user:x` via a
|
||||
* restored hash before this prop has had a chance to gate the click.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
rows: DrilldownRow[]
|
||||
metrics: string[]
|
||||
drill: string
|
||||
disabled: boolean
|
||||
userModelStatsEnabled: boolean
|
||||
/** The global filters store's current window/span (N3 fix) — ClampedRangeNotice's own props, forwarded so this stays a controlled, props-driven component rather than reaching into stores/filters.ts itself. */
|
||||
window: HistoryWindow
|
||||
span: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:drill': [value: string] }>()
|
||||
|
||||
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) }
|
||||
})
|
||||
|
||||
/**
|
||||
* parentGroupLabel is a BEST-EFFORT breadcrumb label for the group a
|
||||
* drilled-into user belongs to — the `drill` param itself only ever
|
||||
* encodes the current LEAF selection (`user:{id}`, redesign-plan.md
|
||||
* section 3.1's own `drill=group:x|user:x` shape has no room for a
|
||||
* compound path), so this is tracked as local component state, set at
|
||||
* the moment a user row is clicked FROM the user-list level. A reader who
|
||||
* lands directly on `drill=user:x` via a restored hash sees no middle
|
||||
* breadcrumb segment — an acceptable, honest degradation (nothing else
|
||||
* available in `drill` records it) rather than a fabricated group name.
|
||||
*/
|
||||
const parentGroupLabel = ref('')
|
||||
|
||||
function goToGroups(): void {
|
||||
emit('update:drill', '')
|
||||
}
|
||||
function goToParentGroup(): void {
|
||||
if (parentGroupLabel.value) emit('update:drill', `group:${parentGroupLabel.value}`)
|
||||
}
|
||||
function onRowClick(id: string): void {
|
||||
const l = level.value
|
||||
if (l.kind === 'group') {
|
||||
emit('update:drill', `group:${id}`)
|
||||
} else if (l.kind === 'user' && props.userModelStatsEnabled) {
|
||||
parentGroupLabel.value = l.group
|
||||
emit('update:drill', `user:${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const idColumnLabel = computed<string>(() => (level.value.kind === 'group' ? 'Group' : level.value.kind === 'user' ? 'User' : 'Model'))
|
||||
|
||||
/** Row id is clickable to drill further at the group level always, and at the user level only when userModelStatsEnabled — the leaf (usermodel) level renders the model id as an EntityLink to the Models page instead, since there is nowhere further to drill. */
|
||||
function idCell(row: DrilldownRow) {
|
||||
const l = level.value
|
||||
if (l.kind === 'usermodel') return h(EntityLink, { label: row.id, kind: 'model', id: row.id })
|
||||
const clickable = l.kind === 'group' || props.userModelStatsEnabled
|
||||
if (!clickable) return h('span', {}, row.id)
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class:
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-sm hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
|
||||
onClick: () => onRowClick(row.id),
|
||||
},
|
||||
[row.id, h(FontAwesomeIcon, { icon: faChevronRight, class: 'size-3', 'aria-hidden': 'true' })],
|
||||
)
|
||||
}
|
||||
|
||||
const columns = computed<ColumnDef<DrilldownRow, unknown>[]>(() => [
|
||||
{ id: 'id', header: idColumnLabel.value, accessorFn: (r) => r.id, cell: ({ row }) => idCell(row.original) },
|
||||
...props.metrics.map(
|
||||
(metric): ColumnDef<DrilldownRow, unknown> => ({
|
||||
id: metric,
|
||||
header: SPEND_METRIC_LABEL[metric as HistoryMetric] ?? metric,
|
||||
meta: { align: 'right' },
|
||||
accessorFn: (r) => r.values[metric] ?? 0,
|
||||
cell: ({ row }) => {
|
||||
const value = row.original.values[metric] ?? 0
|
||||
return metric === 'cost' ? formatCost(value) : h(CompactNumber, { value })
|
||||
},
|
||||
}),
|
||||
),
|
||||
])
|
||||
|
||||
const showUserModelHint = computed<boolean>(() => level.value.kind === 'user' && (!props.userModelStatsEnabled || props.disabled))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Attribution</CardTitle>
|
||||
<CardDescription>Drill from groups into users into the models they used.</CardDescription>
|
||||
<nav aria-label="Breadcrumb" class="flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<button type="button" class="hover:text-foreground hover:underline" :disabled="level.kind === 'group'" @click="goToGroups">
|
||||
All groups
|
||||
</button>
|
||||
<template v-if="level.kind === 'user'">
|
||||
<FontAwesomeIcon :icon="faChevronRight" class="size-3" aria-hidden="true" />
|
||||
<span class="font-medium text-foreground">{{ level.group }}</span>
|
||||
</template>
|
||||
<template v-if="level.kind === 'usermodel'">
|
||||
<FontAwesomeIcon :icon="faChevronRight" class="size-3" aria-hidden="true" />
|
||||
<button v-if="parentGroupLabel" type="button" class="hover:text-foreground hover:underline" @click="goToParentGroup">
|
||||
{{ parentGroupLabel }}
|
||||
</button>
|
||||
<FontAwesomeIcon :icon="faChevronRight" class="size-3" aria-hidden="true" />
|
||||
<span class="font-medium text-foreground">{{ level.user }}</span>
|
||||
</template>
|
||||
</nav>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<ClampedRangeNotice v-if="level.kind === 'usermodel'" :window="window" :span="span" />
|
||||
<p v-if="showUserModelHint" class="text-sm text-muted-foreground">
|
||||
Per-model attribution for this user needs <code class="font-mono text-xs">admin.stats.userModel</code> enabled.
|
||||
</p>
|
||||
<DataTable
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:empty-message="level.kind === 'group' ? 'No group has spend in this range.' : 'Nothing in this range.'"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from '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'
|
||||
|
||||
/**
|
||||
* BurnDownChart (redesign-plan.md section 3.4) renders the current UTC
|
||||
* calendar month's cumulative spend against its configured budget: a
|
||||
* solid line (lib/burndown.ts's `cumulative`, run over the store's own raw
|
||||
* per-day points — see stores/spend.ts's own doc comment on why the
|
||||
* running-sum step lives here rather than in the store) plus, when a
|
||||
* budget is configured, a dashed reference line at that flat limit
|
||||
* (TimeSeriesChart's own `comparisonDatasets` — a budget ceiling is
|
||||
* exactly the "reference overlay, not a component of the current total"
|
||||
* that prop already documents) and the projected run-out date
|
||||
* (lib/burndown.ts's runOutDate, MTD average daily rate).
|
||||
*
|
||||
* `now` is a required prop, not read from `new Date()` internally —
|
||||
* CostForecastCard.vue's own doc comment explains why a component-local
|
||||
* default silently freezes at first mount (Vue caches a prop default
|
||||
* once per instance): the caller passes the SAME shared clock
|
||||
* (composables/useNow.ts) every other time-dependent read on the page
|
||||
* shares.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** Day-window bucket keys, oldest first (limits.go's "20060102" format — lib/format.ts's formatBucketLabel). */
|
||||
buckets: string[]
|
||||
/** Raw (not cumulative) per-day cost in micro-USD, index-aligned with `buckets`. */
|
||||
points: number[]
|
||||
/** The scope's configured cost/month budget in micro-USD, or null when none is configured. */
|
||||
budgetMicros: number | null
|
||||
now: Date
|
||||
}>()
|
||||
|
||||
const labels = computed<string[]>(() => props.buckets.map((b) => formatBucketLabel(b, 'day')))
|
||||
const cumulativePoints = computed<number[]>(() => cumulative(props.points))
|
||||
const spentMicros = computed<number>(() => cumulativePoints.value.at(-1) ?? 0)
|
||||
|
||||
const datasets = computed<TimeSeriesDataset[]>(() => [{ label: 'Spent (MTD)', data: cumulativePoints.value, color: '--chart-cost' }])
|
||||
|
||||
const comparisonDatasets = computed<TimeSeriesDataset[]>(() => {
|
||||
if (props.budgetMicros === null) return []
|
||||
const flat = new Array<number>(cumulativePoints.value.length).fill(props.budgetMicros)
|
||||
return [{ label: 'Budget', data: flat, color: '--destructive' }]
|
||||
})
|
||||
|
||||
const runOut = computed<Date | null>(() => (props.budgetMicros === null ? null : runOutDate(spentMicros.value, props.budgetMicros, props.now)))
|
||||
|
||||
const willExceed = computed<boolean>(() => props.budgetMicros !== null && spentMicros.value >= props.budgetMicros)
|
||||
</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">
|
||||
<div class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Spent so far</p>
|
||||
<p class="text-lg font-semibold tabular-nums" :class="willExceed ? 'text-destructive' : undefined">
|
||||
{{ formatCost(spentMicros) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="budgetMicros !== null">
|
||||
<p class="text-muted-foreground">Budget</p>
|
||||
<p class="text-lg font-semibold tabular-nums">{{ formatCost(budgetMicros) }}</p>
|
||||
</div>
|
||||
<div v-if="budgetMicros !== null">
|
||||
<p class="text-muted-foreground">Projected run-out</p>
|
||||
<p v-if="runOut === null" class="text-sm text-muted-foreground">not enough data yet</p>
|
||||
<p v-else class="text-lg font-semibold tabular-nums" :class="willExceed ? 'text-destructive' : undefined">
|
||||
{{ runOut.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="budgetMicros === null" class="text-sm text-muted-foreground">No budget configured for this scope.</p>
|
||||
<TimeSeriesChart
|
||||
:labels="labels"
|
||||
:datasets="datasets"
|
||||
:comparison-datasets="comparisonDatasets"
|
||||
:value-formatter="formatCost"
|
||||
ariaLabel="Cumulative spend this month against the configured budget"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import GrantForm from '@/components/forms/GrantForm.vue'
|
||||
import LimitsForm from '@/components/forms/LimitsForm.vue'
|
||||
import ModelMetaForm from '@/components/forms/ModelMetaForm.vue'
|
||||
import PricingForm from '@/components/forms/PricingForm.vue'
|
||||
import UserForm from '@/components/forms/UserForm.vue'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
|
||||
/** HELPER_IDS mirrors the Config page's own `helper` hash param (redesign-plan.md section 3.1: `helper=limits|grant|pricing|model|user`) — the single source of truth for which tab is selected and which form renders. */
|
||||
const HELPER_IDS = ['limits', 'grant', 'pricing', 'model', 'user'] as const
|
||||
type HelperId = (typeof HELPER_IDS)[number]
|
||||
|
||||
const HELPER_LABEL: Record<HelperId, string> = {
|
||||
limits: 'Limits',
|
||||
grant: 'Access grant',
|
||||
pricing: 'Pricing',
|
||||
model: 'Model metadata',
|
||||
user: 'New user',
|
||||
}
|
||||
|
||||
/**
|
||||
* ChangeHelper (redesign-plan.md section 3.4) is the Config page's
|
||||
* snippet-building surface: five forms (LimitsForm, GrantForm,
|
||||
* PricingForm, ModelMetaForm, UserForm), each producing a paste-ready
|
||||
* YAML/users.json fragment via lib/snippets.ts, switched by the page's own
|
||||
* `helper` hash param so a shared link opens the same tab.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
groupNames: string[]
|
||||
userNames: string[]
|
||||
}>()
|
||||
|
||||
const nav = useNavStore()
|
||||
|
||||
const helper = computed<HelperId>(() => {
|
||||
const value = nav.params.helper
|
||||
return (HELPER_IDS as readonly string[]).includes(value ?? '') ? (value as HelperId) : 'limits'
|
||||
})
|
||||
|
||||
function setHelper(next: unknown): void {
|
||||
if (typeof next !== 'string' || !(HELPER_IDS as readonly string[]).includes(next)) return
|
||||
nav.goTo('config', { ...nav.params, helper: next })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Tabs :model-value="helper" @update:model-value="setHelper">
|
||||
<TabsList>
|
||||
<TabsTrigger v-for="id in HELPER_IDS" :key="id" :value="id">{{ HELPER_LABEL[id] }}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<LimitsForm v-if="helper === 'limits'" :group-names="props.groupNames" :user-names="props.userNames" />
|
||||
<GrantForm v-else-if="helper === 'grant'" :group-names="props.groupNames" :user-names="props.userNames" />
|
||||
<PricingForm v-else-if="helper === 'pricing'" />
|
||||
<ModelMetaForm v-else-if="helper === 'model'" />
|
||||
<UserForm v-else :group-names="props.groupNames" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,281 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import CsvExportButton from '@/components/CsvExportButton.vue'
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import ModelUsageChart from '@/components/ModelUsageChart.vue'
|
||||
import UsageChart from '@/components/UsageChart.vue'
|
||||
import { useSearchQuery } from '@/composables/useSearchQuery'
|
||||
import { filterModelsByPrefix } from '@/lib/model-filter'
|
||||
import { modelTableColumns } from '@/lib/model-table-columns'
|
||||
import { modelDetailCsv } from '@/lib/usage-csv'
|
||||
import { groupMatches, userMatches } from '@/lib/usage-search'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import {
|
||||
type ChartTab,
|
||||
MODEL_METRIC_LABEL,
|
||||
MODEL_RANKING_LIMIT,
|
||||
type ModelMetric,
|
||||
type TimeSeriesTab,
|
||||
useHistoryStore,
|
||||
WINDOW_LABEL,
|
||||
WINDOW_SPAN,
|
||||
} from '@/stores/history'
|
||||
import type { HistoryWindow } from '@/types/api'
|
||||
|
||||
const dashboard = useDashboardStore()
|
||||
const history = useHistoryStore()
|
||||
|
||||
// --- user/group search filter (operator feature) ---
|
||||
//
|
||||
// Filters the scope picker's user/group list only — "total" always stays
|
||||
// reachable (unconditionally the list's first entry, below), and the
|
||||
// CURRENTLY SELECTED scope is always kept in the list even if it stops
|
||||
// matching a new query: the filter narrows what's OFFERED, it must never
|
||||
// force-switch (or hide the label of) whatever chart is already rendered.
|
||||
// Same shared SearchInput/useSearchQuery and lib/usage-search.ts matching
|
||||
// helpers as UsageView.vue — one implementation, no second copy.
|
||||
const { query: scopeQuery, normalized: normalizedScopeQuery, hasQuery: hasScopeQuery } = useSearchQuery()
|
||||
|
||||
const scopeOptions = computed(() => {
|
||||
const allUsers = dashboard.usage?.users ?? []
|
||||
const allGroups = dashboard.usage?.groups ?? []
|
||||
const users = allUsers
|
||||
.filter((u) => !hasScopeQuery.value || userMatches(u, normalizedScopeQuery.value) || `user:${u.id}` === history.scope)
|
||||
.map((u) => ({ value: `user:${u.id}`, label: u.id }))
|
||||
const groups = allGroups
|
||||
.filter(
|
||||
(g) =>
|
||||
!hasScopeQuery.value || groupMatches(g, allUsers, normalizedScopeQuery.value) || `group:${g.id}` === history.scope,
|
||||
)
|
||||
.map((g) => ({ value: `group:${g.id}`, label: g.id }))
|
||||
// Models come from the ranking endpoint, which returns only models with
|
||||
// non-zero traffic (operator requirement: the catalog is hundreds of
|
||||
// models, so the picker must never list idle ones). Matched on the plain
|
||||
// id rather than through lib/usage-search's helpers — those match a
|
||||
// user's or group's own shape, and a model entry has neither.
|
||||
const models = (history.modelOptions ?? [])
|
||||
.filter(
|
||||
(m) =>
|
||||
!hasScopeQuery.value ||
|
||||
m.id.toLowerCase().includes(normalizedScopeQuery.value) ||
|
||||
`model:${m.id}` === history.scope,
|
||||
)
|
||||
.map((m) => ({ value: `model:${m.id}`, label: m.id }))
|
||||
// A model already being charted stays in the list even once it drops out
|
||||
// of modelOptions entirely — which happens the moment it goes idle for
|
||||
// the selected window, since that list is non-zero-only. Without this,
|
||||
// the watch below would read it as a vanished scope and force-switch the
|
||||
// reader to "total" mid-look, for the ordinary reason that a model
|
||||
// simply stopped receiving traffic.
|
||||
if (history.scope.startsWith('model:') && !models.some((m) => m.value === history.scope)) {
|
||||
models.push({ value: history.scope, label: history.scope.slice('model:'.length) })
|
||||
}
|
||||
return [{ value: 'total', label: 'Total (all traffic)' }, ...groups, ...users, ...models]
|
||||
})
|
||||
|
||||
const windows: HistoryWindow[] = ['hour', 'day', 'month']
|
||||
const modelMetrics: ModelMetric[] = ['cost', 'req', 'tokin', 'tokout']
|
||||
|
||||
/**
|
||||
* modelColumns is a stateless ColumnDef set (lib/model-table-columns.ts),
|
||||
* built once rather than per-render — the same convention
|
||||
* TargetsView.vue's own `columns` constant follows for its identical
|
||||
* reason (no per-row closures capture component state, so there is
|
||||
* nothing to rebuild).
|
||||
*/
|
||||
const modelColumns = modelTableColumns()
|
||||
|
||||
// --- Models-tab prefix filter (F6, dashboard-plan.md) ---
|
||||
//
|
||||
// history.modelFilter is set by ProvidersView.vue's provider-header link
|
||||
// (nav.goToModels(`${p.name}/`), stores/nav.ts — WP-B1) or restored from
|
||||
// the `#charts?tab=models&filter=...` hash (lib/hash-state.ts). P3 review
|
||||
// fix: setModelFilter now also sends the prefix to the SERVER and
|
||||
// refetches (stores/history.ts's own state doc comment on modelFilter),
|
||||
// so this re-slice is a harmless second pass over an already-narrowed
|
||||
// ranking, not the only filtering step — see lib/model-filter.ts's own
|
||||
// doc comment.
|
||||
const filteredModelRanking = computed(() => filterModelsByPrefix(history.modelRanking, history.modelFilter))
|
||||
|
||||
/**
|
||||
* Exports exactly the ranking rows on screen (filter applied, server order
|
||||
* kept). free-models-plan.md: every ranking fetch now requests detail=1
|
||||
* (stores/history.ts's fetchModelRanking), so the export carries every
|
||||
* per-row figure (requests/tokens in/tokens out/cost/free) via
|
||||
* modelDetailCsv, not just the single metric currently being ranked by —
|
||||
* matching the on-screen table (lib/model-table-columns.ts) exactly,
|
||||
* regardless of which metric history.modelMetric happens to be.
|
||||
*/
|
||||
function modelsCsv(): string {
|
||||
return modelDetailCsv(filteredModelRanking.value, WINDOW_LABEL[history.window], WINDOW_SPAN[history.window])
|
||||
}
|
||||
|
||||
function clearModelFilter(): void {
|
||||
history.setModelFilter('')
|
||||
}
|
||||
|
||||
/**
|
||||
* UsageChart only ever renders on a time-series tab (the template's v-else
|
||||
* below), but a `v-else` narrows nothing for the type checker — this does.
|
||||
* The 'requests' stand-in is never displayed: it is the value handed over
|
||||
* on the one tab where the component is not rendered at all.
|
||||
*/
|
||||
const timeSeriesTab = computed<TimeSeriesTab>(() => (history.tab === 'models' ? 'requests' : history.tab))
|
||||
|
||||
function onTabChange(value: string | number): void {
|
||||
history.setTab(value as ChartTab)
|
||||
}
|
||||
function onScopeChange(value: unknown): void {
|
||||
if (typeof value === 'string') history.setScope(value)
|
||||
}
|
||||
function onModelMetricChange(value: unknown): void {
|
||||
if (typeof value === 'string') history.setModelMetric(value as ModelMetric)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// The picker's model list is fetched here (initial load) and again on
|
||||
// any window change (history.ts's own setWindow) — nowhere else (Q1,
|
||||
// dashboard-plan.md DECISIONS). The selection's own series/ranking
|
||||
// fetch no longer needs a separate call here: startAutoRefresh's poller
|
||||
// (lib/polling.ts) ticks once immediately on start, which is that
|
||||
// initial fetch.
|
||||
void history.fetchModelOptions()
|
||||
history.startAutoRefresh()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
history.stopAutoRefresh()
|
||||
})
|
||||
|
||||
// The scope list is only known once the Usage response has loaded — if the
|
||||
// previously-selected scope disappears (a user/group config change), fall
|
||||
// back to the total scope rather than requesting a now-unknown id. This is
|
||||
// ALSO what guards a bogus scope restored from a hand-edited URL hash (F9,
|
||||
// dashboard-plan.md: lib/hash-state.ts's parseChartsParams only validates
|
||||
// SHAPE — "total"/"user:.+"/"group:.+"/"model:.+" — never that the id
|
||||
// actually exists): `watch` without `{ immediate: true }` skips its first
|
||||
// evaluation, so a hash-restored scope like "user:ghost" survives the
|
||||
// (likely still-empty, pre-fetch) initial scopeOptions and is only judged
|
||||
// once dashboard.usage genuinely loads and this computed's value actually
|
||||
// changes — at which point a truly nonexistent id resets to 'total' here,
|
||||
// exactly like any other vanished scope.
|
||||
watch(scopeOptions, (options) => {
|
||||
if (!options.some((o) => o.value === history.scope)) {
|
||||
history.setScope('total')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Usage charts</CardTitle>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- The Models tab has no scope picker to filter (its own Select branch below is the ranking-metric picker instead) — a visible search box that silently does nothing is worse than no box at all. -->
|
||||
<SearchInput
|
||||
v-if="history.tab !== 'models'"
|
||||
v-model="scopeQuery"
|
||||
placeholder="Filter users, groups or models"
|
||||
class="w-56"
|
||||
/>
|
||||
|
||||
<Select
|
||||
v-if="history.tab === 'models'"
|
||||
:model-value="history.modelMetric"
|
||||
@update:model-value="onModelMetricChange"
|
||||
>
|
||||
<SelectTrigger class="w-40" aria-label="Rank models by">
|
||||
<SelectValue placeholder="Rank by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="m in modelMetrics" :key="m" :value="m">
|
||||
{{ MODEL_METRIC_LABEL[m] }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select v-else :model-value="history.scope" @update:model-value="onScopeChange">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Scope" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="opt in scopeOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Tabs :model-value="history.window" @update:model-value="(v) => history.setWindow(v as HistoryWindow)">
|
||||
<TabsList>
|
||||
<TabsTrigger v-for="w in windows" :key="w" :value="w">{{ WINDOW_LABEL[w] }}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-4">
|
||||
<Tabs :model-value="history.tab" @update:model-value="onTabChange">
|
||||
<TabsList>
|
||||
<TabsTrigger value="requests">Requests</TabsTrigger>
|
||||
<TabsTrigger value="tokens">Tokens</TabsTrigger>
|
||||
<TabsTrigger value="cost">Cost</TabsTrigger>
|
||||
<TabsTrigger value="models">Models</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<p v-if="history.error" class="text-sm text-destructive">{{ history.error }}</p>
|
||||
<!-- Only until the current selection's first fetch lands (history.loaded) — background refreshes never flash it over rendered data. -->
|
||||
<p v-else-if="history.loading && !history.loaded" class="text-sm text-muted-foreground">Loading…</p>
|
||||
<template v-if="history.tab === 'models'">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Models with usage over the last {{ WINDOW_LABEL[history.window] }}, ranked by
|
||||
{{ MODEL_METRIC_LABEL[history.modelMetric].toLowerCase() }}. Each row is the provider that served the
|
||||
traffic, so a model reached through more than one provider appears once per provider.
|
||||
</p>
|
||||
<p v-if="history.modelFilter" class="flex items-center gap-1.5 text-sm">
|
||||
<span class="text-muted-foreground">Filtered to</span>
|
||||
<span class="font-mono text-xs">{{ history.modelFilter }}</span>
|
||||
<Button variant="ghost" size="icon-xs" aria-label="Clear model filter" @click="clearModelFilter">
|
||||
<FontAwesomeIcon :icon="faXmark" class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</p>
|
||||
<!-- P3: the ranking is a top-N (MODEL_RANKING_LIMIT), never the complete set — say so whenever the fetched ranking is exactly that long, so a full list doesn't silently read as "this is everything". -->
|
||||
<p v-if="history.modelRanking.length === MODEL_RANKING_LIMIT" class="text-xs text-muted-foreground">
|
||||
Top {{ MODEL_RANKING_LIMIT }} models
|
||||
</p>
|
||||
<ModelUsageChart :metric="history.modelMetric" :models="filteredModelRanking" />
|
||||
<!--
|
||||
free-models-plan.md: the per-model detail table beneath the
|
||||
chart, with a per-row Requests/Tokens in/Tokens out/Cost
|
||||
breakdown (a free model shows a "free" badge instead of $0 —
|
||||
lib/model-table-columns.ts). Guarded by the same
|
||||
filteredModelRanking.length check ModelUsageChart's own empty
|
||||
state already uses above, so an empty window shows exactly ONE
|
||||
"no model recorded any usage" message, not a second, empty
|
||||
table stacked under it.
|
||||
-->
|
||||
<DataTable
|
||||
v-if="filteredModelRanking.length"
|
||||
:columns="modelColumns"
|
||||
:data="filteredModelRanking"
|
||||
empty-message="No model recorded any usage in this window."
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<CsvExportButton :filename="`usage-models-${history.window}.csv`" :build="modelsCsv" />
|
||||
</div>
|
||||
</template>
|
||||
<UsageChart v-else :tab="timeSeriesTab" :window="history.window" :series-by-metric="history.seriesByMetric" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { dayOrMonthClampNotice } from '@/lib/range'
|
||||
import type { HistoryWindow } from '@/types/api'
|
||||
|
||||
/**
|
||||
* ClampedRangeNotice (N3 fix, verify-redesign-final.md) is the ONE shared
|
||||
* "this view silently clamped your range" banner for every day/month-only
|
||||
* endpoint reached through lib/range.ts's dayOrMonthWindow:
|
||||
* - UserDetail.vue's "Models used" table (usermodel totals)
|
||||
* - AttributionDrilldown.vue's usermodel level (the Spend drilldown)
|
||||
* - TargetCallers.vue (targetcaller totals)
|
||||
* An hour-resolution global range (24h/48h) has no day/month equivalent
|
||||
* for any of these — dayOrMonthWindow silently substitutes a fixed
|
||||
* 7-day day-window instead, and this renders nothing (dayOrMonthClampNotice
|
||||
* returns null) unless that substitution actually happened, so a
|
||||
* day/month-window reader never sees a spurious banner.
|
||||
*
|
||||
* Distinct from ReliabilityPage.vue's own inline month-clamp Alert — a
|
||||
* DIFFERENT clamp (hour/day -> a fixed 30-day window, stores/
|
||||
* reliability.ts's hourOrDayWindow) that stays its own local computed,
|
||||
* not this component.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
window: HistoryWindow
|
||||
span: number
|
||||
}>()
|
||||
|
||||
const note = computed<string | null>(() => dayOrMonthClampNotice(props.window, props.span))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Alert v-if="note" variant="warn">
|
||||
<AlertDescription>{{ note }}</AlertDescription>
|
||||
</Alert>
|
||||
</template>
|
||||
@@ -9,8 +9,8 @@ import { formatCompactCount, formatExactInt } from '@/lib/format'
|
||||
* (mouse hover) and a visually-hidden sibling span (keyboard/screen-
|
||||
* reader parity). Used anywhere a usage count renders: DataTable numeric
|
||||
* columns (usage-columns.ts, target-columns.ts, via
|
||||
* `h(CompactNumber, { value })`), the Usage tab's Total card, and a
|
||||
* group accordion's trigger/detail stat grid (UsageView.vue).
|
||||
* `h(CompactNumber, { value })`), the Consumers page's Total row, and a
|
||||
* group accordion's trigger/detail stat grid (ConsumerDirectory.vue).
|
||||
*
|
||||
* Review fix (folded minor, ARIA): `aria-label` on a plain `<span>` is
|
||||
* an axe `aria-prohibited-attr` violation — a `<span>`'s implicit ARIA
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SnippetBlock from '@/components/SnippetBlock.vue'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { emitYamlDocument, toYamlValue } from '@/lib/yaml-emit'
|
||||
import type { YamlValue } from '@/lib/yaml-emit'
|
||||
|
||||
/**
|
||||
* ConfigTree (redesign-plan.md section 3.4) renders GET /admin/api/config's
|
||||
* already-redacted `config` (admin_config.go's redactConfig — every
|
||||
* apiKey/password literal replaced with "[redacted]", an "env:NAME"/
|
||||
* "file:/path" indirection kept, every baseUrl/url stripped of userinfo)
|
||||
* as read-only YAML via lib/yaml-emit.ts's generic recursive renderer —
|
||||
* safe to render as-is, never re-sanitized client-side (types/api.ts's
|
||||
* AdminConfigResponse doc comment).
|
||||
*/
|
||||
const props = defineProps<{
|
||||
config: Record<string, unknown>
|
||||
warnings: string[]
|
||||
}>()
|
||||
|
||||
const yamlText = computed(() => {
|
||||
const value = toYamlValue(props.config) as Record<string, YamlValue | undefined>
|
||||
return emitYamlDocument(value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Alert v-for="(warning, index) in warnings" :key="index" variant="warn">
|
||||
<AlertTitle>Config warning</AlertTitle>
|
||||
<AlertDescription>{{ warning }}</AlertDescription>
|
||||
</Alert>
|
||||
<SnippetBlock label="Redacted running config (read-only)" :text="yamlText" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,13 +2,11 @@
|
||||
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 { storeToRefs } from 'pinia'
|
||||
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import CostForecastCard from '@/components/CostForecastCard.vue'
|
||||
import CsvExportButton from '@/components/CsvExportButton.vue'
|
||||
import ModelChip from '@/components/ModelChip.vue'
|
||||
import ScopeLink from '@/components/ScopeLink.vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import SortHeaderButton from '@/components/SortHeaderButton.vue'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
@@ -19,7 +17,6 @@ import UsageBar from '@/components/UsageBar.vue'
|
||||
import UsageTable from '@/components/UsageTable.vue'
|
||||
import { useNow } from '@/composables/useNow'
|
||||
import { useSearchQuery } from '@/composables/useSearchQuery'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
import { headroom, monthProgress, projectMonthEnd } from '@/lib/forecast'
|
||||
import { formatCost, formatExactInt, formatLimits } from '@/lib/format'
|
||||
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
|
||||
@@ -27,19 +24,37 @@ import { BUDGET_RATIO_LABEL, type BudgetRatio, budgetRatios } from '@/lib/usage-
|
||||
import { usageColumns } from '@/lib/usage-columns'
|
||||
import { usageCsv } from '@/lib/usage-csv'
|
||||
import { groupMatches, groupNameMatches, matchingMembersOfGroup, membersOfGroup, userMatches } from '@/lib/usage-search'
|
||||
import { useConsumersStore } from '@/stores/consumers'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
import type { AdminUsageEntryView } from '@/types/api'
|
||||
|
||||
/**
|
||||
* ConsumerDirectory (redesign-plan.md section 3.4) is the Consumers page's
|
||||
* default view: the Users/Groups usage-and-access directory that used to
|
||||
* be UsageView.vue's whole tab, moved here verbatim (the Groups accordion
|
||||
* + its access block included, per the coordinator's own instruction) with
|
||||
* three redesign-era changes: nav.usageQuery -> the page's own `q` hash
|
||||
* param (stores/nav.ts no longer special-cases a search string), the id
|
||||
* cell's ScopeLink -> EntityLink (usage-columns.ts, opens this row's own
|
||||
* UserDetail instead of jumping to the old flat Charts tab), and a new
|
||||
* Source column (inline/file, GET /admin/api/consumers) on the flat Users
|
||||
* table only.
|
||||
*/
|
||||
const dashboard = useDashboardStore()
|
||||
const consumers = useConsumersStore()
|
||||
const nav = useNavStore()
|
||||
const usage = computed(() => dashboard.usage)
|
||||
|
||||
// The one shared reactive clock every time-dependent read on this tab uses
|
||||
onMounted(() => {
|
||||
void consumers.ensureConsumers()
|
||||
})
|
||||
|
||||
// The one shared reactive clock every time-dependent read on this page uses
|
||||
// (P1, composables/useNow.ts's own doc comment) — CostForecastCard's own
|
||||
// elapsedFraction, usageColumns()'s `now` parameter (both the real Users/
|
||||
// Members <table>s below and the headless Groups toolbar's groupColumns),
|
||||
// and groupCostProjection all read THIS ref, never their own `new Date()`,
|
||||
// so a long-open tab's cost projections advance together instead of
|
||||
// freezing at whatever instant each one happened to first run.
|
||||
// and groupCostProjection all read THIS ref, never their own `new Date()`.
|
||||
const { now, start: startClock, stop: stopClock } = useNow()
|
||||
onMounted(startClock)
|
||||
onUnmounted(stopClock)
|
||||
@@ -60,25 +75,24 @@ function memberCountOf(entry: AdminUsageEntryView): string {
|
||||
return count === undefined ? '' : String(count)
|
||||
}
|
||||
|
||||
// --- user/group search filter (operator feature, mirrors
|
||||
// ProvidersView.vue's model search via the shared SearchInput component and
|
||||
// useSearchQuery composable — same styling, same clear-button behavior.
|
||||
// Matching logic lives in lib/usage-search.ts, which ChartsView.vue's own
|
||||
// scope-picker filter calls directly too (groupMatches, userMatches) — no
|
||||
// second copy of "does this user/group match?") ---
|
||||
// Bound to nav.usageQuery so the search text round-trips through the URL hash (#usage?q=…, lib/hash-state.ts).
|
||||
const { usageQuery } = storeToRefs(useNavStore())
|
||||
/** sourceOf (Consumers redesign) joins a usage entry's id against GET /admin/api/consumers' own users list — 'inline' or 'file' (admin.go: adminConsumerUser.Source), undefined until that on-demand fetch resolves or for an id it has no match for (a group row never has one). Passed to usage-columns.ts's usageColumns() as `sourceValue` — see that module's sourceColumn doc comment. */
|
||||
function sourceOf(entry: AdminUsageEntryView): string | undefined {
|
||||
return consumers.data?.users.find((u) => u.name === entry.id)?.source
|
||||
}
|
||||
|
||||
// --- user/group search filter (operator feature) ---
|
||||
// Matching logic lives in lib/usage-search.ts. Bound to nav.params.q so
|
||||
// the search text round-trips through the URL hash (#consumers?q=...,
|
||||
// lib/hash-state.ts) — stores/nav.ts's own `params` is opaque, so this
|
||||
// page owns interpreting/writing its own `q` key directly, rather than
|
||||
// the old nav.usageQuery special case.
|
||||
const usageQuery = computed<string>({
|
||||
get: () => nav.params.q ?? '',
|
||||
set: (value) => nav.goTo('consumers', { ...nav.params, q: value }),
|
||||
})
|
||||
const { query: userQuery, normalized: normalizedQuery, hasQuery } = useSearchQuery(usageQuery)
|
||||
|
||||
/**
|
||||
* filteredGroups is every group matching the query — own name OR a
|
||||
* matching member (lib/usage-search.ts's groupMatches, binding semantics)
|
||||
* — or every group when there is no query. This feeds groupsTable's
|
||||
* `data()` below directly, so filtering happens upstream of sorting: the
|
||||
* Groups sort toolbar and the Accordion both operate on the same
|
||||
* (possibly narrowed) list, no separate filtering pass downstream of the
|
||||
* table.
|
||||
*/
|
||||
/** filteredGroups is every group matching the query — own name OR a matching member (lib/usage-search.ts's groupMatches) — or every group when there is no query. */
|
||||
const filteredGroups = computed<AdminUsageEntryView[]>(() => {
|
||||
const all = usage.value?.groups ?? []
|
||||
if (!hasQuery.value) return all
|
||||
@@ -86,101 +100,46 @@ const filteredGroups = computed<AdminUsageEntryView[]>(() => {
|
||||
return all.filter((g) => groupMatches(g, users, normalizedQuery.value))
|
||||
})
|
||||
|
||||
/**
|
||||
* memberOnlyMatchIds is filteredGroups narrowed to groups that matched
|
||||
* ONLY via a member, never via their own name — the auto-expand target
|
||||
* set (search-expand.ts's computeExpandedItems `matchingIds`).
|
||||
*
|
||||
* This is a DELIBERATE divergence from ProvidersView.vue, not an
|
||||
* oversight, and NOT because Providers lacks a provider-name match —
|
||||
* it doesn't lack one: providerMatches there (ProvidersView.vue) checks
|
||||
* each model's full ROUTABLE id, `${provider}/${model}`
|
||||
* (lib/format.ts's routableModelId), so a query like "openai" matches
|
||||
* every "openai/..." model id and the whole provider auto-expands with
|
||||
* every one of them showing. That is correct there — a provider's own
|
||||
* model list IS what the search is for, so revealing it is the point,
|
||||
* and EVERY provider match (name-flavored or not) auto-expands.
|
||||
*
|
||||
* Groups differ: a group's own name is already fully legible on its
|
||||
* COLLAPSED trigger row (the `<AccordionTrigger>` below) — no expansion
|
||||
* needed to read a name match. Auto-expanding every name-matched group too
|
||||
* would dump every one of their full member rosters onto the screen for a
|
||||
* query that only needed the trigger row to already be visible. A member-ONLY
|
||||
* match has no such shortcut — the member that matched stays invisible
|
||||
* until the group opens — so that case alone still auto-expands.
|
||||
* search-expand.ts itself has no opinion on any of this; it is purely
|
||||
* this view's own choice of matchingIds.
|
||||
*/
|
||||
/** memberOnlyMatchIds is filteredGroups narrowed to groups that matched ONLY via a member, never via their own name — the auto-expand target set (search-expand.ts's computeExpandedItems `matchingIds`). A group's own name is already legible on its collapsed trigger row, so only a member-only match needs auto-expansion to reveal what matched. */
|
||||
const memberOnlyMatchIds = computed<string[]>(() => {
|
||||
if (!hasQuery.value) return []
|
||||
return filteredGroups.value.filter((g) => !groupNameMatches(g, normalizedQuery.value)).map((g) => g.id)
|
||||
})
|
||||
|
||||
/**
|
||||
* visibleMembers implements the members-table distinction (binding
|
||||
* semantics): a group matched by its OWN name shows every member
|
||||
* (membersOfGroup); a group that matched only via a member shows only
|
||||
* the matching ones — the exported, tested matchingMembersOfGroup
|
||||
* helper (lib/usage-search.ts), not a re-filter of userMatches here. No
|
||||
* active query behaves like a name match — show everyone, today's
|
||||
* behavior unchanged. Replaces the former local membersOf helper.
|
||||
*/
|
||||
/** visibleMembers: a group matched by its OWN name shows every member; a group that matched only via a member shows only the matching ones. */
|
||||
function visibleMembers(group: AdminUsageEntryView): AdminUsageEntryView[] {
|
||||
const users = usage.value?.users ?? []
|
||||
if (!hasQuery.value || groupNameMatches(group, normalizedQuery.value)) return membersOfGroup(group, users)
|
||||
return matchingMembersOfGroup(group, users, normalizedQuery.value)
|
||||
}
|
||||
|
||||
/** groupsEmptyMessage mirrors ProvidersView.vue's aliasEmptyMessage three-way pattern: distinguishes nothing configured from nothing matching the active query, so an empty Groups card never reads the same regardless of why it's empty. */
|
||||
const groupsEmptyMessage = computed(() =>
|
||||
!usage.value?.groups.length ? 'none configured' : hasQuery.value ? `no groups match "${userQuery.value}"` : 'none',
|
||||
)
|
||||
|
||||
/**
|
||||
* filteredUsers is every user matching the query directly, PLUS every
|
||||
* member of a group that matched by NAME (semantics item 5: "a
|
||||
* group-name match includes all of that group's member rows"). A group
|
||||
* that matched only via ONE member must not pull its other, non-matching
|
||||
* members into this flat table too — that narrower distinction is
|
||||
* visibleMembers' job, scoped to the nested table inside an expanded
|
||||
* group, not this one.
|
||||
*/
|
||||
/** filteredUsers is every user matching the query directly, PLUS every member of a group that matched by NAME. */
|
||||
const filteredUsers = computed<AdminUsageEntryView[]>(() => {
|
||||
const all = usage.value?.users ?? []
|
||||
if (!hasQuery.value) return all
|
||||
const groups = usage.value?.groups ?? []
|
||||
const nameMatchedGroupIds = new Set(
|
||||
groups.filter((g) => groupNameMatches(g, normalizedQuery.value)).map((g) => g.id),
|
||||
)
|
||||
const nameMatchedGroupIds = new Set(groups.filter((g) => groupNameMatches(g, normalizedQuery.value)).map((g) => g.id))
|
||||
return all.filter(
|
||||
(u) =>
|
||||
userMatches(u, normalizedQuery.value) ||
|
||||
(u.groups ?? [u.groupName]).some((g) => g !== undefined && nameMatchedGroupIds.has(g)),
|
||||
(u) => userMatches(u, normalizedQuery.value) || (u.groups ?? [u.groupName]).some((g) => g !== undefined && nameMatchedGroupIds.has(g)),
|
||||
)
|
||||
})
|
||||
/** usersEmptyMessage mirrors groupsEmptyMessage's three-way pattern (see its own doc comment). */
|
||||
const usersEmptyMessage = computed(() =>
|
||||
!usage.value?.users.length ? 'none configured' : hasQuery.value ? `no users match "${userQuery.value}"` : 'none',
|
||||
)
|
||||
|
||||
// --- Groups accordion (operator directive) ---
|
||||
// --- Groups accordion ---
|
||||
//
|
||||
// Groups render as a shadcn-vue Accordion, same component and rationale
|
||||
// as Providers (ProvidersView.vue) — expanding a group shows its member
|
||||
// users' own usage rows below it. A group's row cannot ALSO be a literal
|
||||
// <table> row for the identical reason Providers' old expandable row was
|
||||
// retired: AccordionItem wraps its trigger+content in a <div>, illegal
|
||||
// directly inside a <tbody>/<tr>.
|
||||
//
|
||||
// "Sortable groups usage" (operator directive) is therefore implemented
|
||||
// headlessly: the SAME usageColumns() defs used by the real <table>-based
|
||||
// UsageTable (Users, and each group's own nested member list) drive a
|
||||
// second, unrendered useVueTable instance here purely for its
|
||||
// getSortedRowModel() — sortedGroupRows below. The small button toolbar
|
||||
// under the Groups heading toggles that sort using the identical
|
||||
// SortHeaderButton DataTable.vue's real <th>s use (same icons, same
|
||||
// toggleSorting call, same accessible name), just laid out as a toolbar
|
||||
// instead of <th>s, because there is no <table> here to put a <th> in.
|
||||
// Groups render as a shadcn-vue Accordion — expanding a group shows its
|
||||
// member users' own usage rows below it. "Sortable groups usage" is
|
||||
// implemented headlessly: the SAME usageColumns() defs the real <table>-
|
||||
// based UsageTable uses drive a second, unrendered useVueTable instance
|
||||
// here purely for its getSortedRowModel() — sortedGroupRows below. The
|
||||
// small button toolbar under the Groups heading toggles that sort using
|
||||
// the identical SortHeaderButton DataTable.vue's real <th>s use.
|
||||
const groupColumns = computed(() => usageColumns('Name', 'Members', memberCountOf, now.value))
|
||||
const groupSorting = ref<SortingState>([])
|
||||
const groupsTable = useVueTable({
|
||||
@@ -201,36 +160,16 @@ const groupsTable = useVueTable({
|
||||
})
|
||||
const sortedGroupRows = computed(() => groupsTable.getRowModel().rows)
|
||||
|
||||
/**
|
||||
* TOOLBAR_COLUMN_IDS restricts the toolbar to exactly the fields each
|
||||
* trigger row displays (Name, Members, req/day, cost/day) — review fix:
|
||||
* exposing all 11 usageColumns() fields as sortable when only 4 are
|
||||
* visible per row let the accordion's order silently diverge from what
|
||||
* the toolbar visibly claims to control. The full stat set stays
|
||||
* available inside each expanded item's own detail grid, just not as a
|
||||
* sort key here.
|
||||
*/
|
||||
/** TOOLBAR_COLUMN_IDS restricts the toolbar to exactly the fields each trigger row displays (Name, Members, req/day, cost/day) — the full stat set stays available inside each expanded item's own detail grid, just not as a sort key here. */
|
||||
const TOOLBAR_COLUMN_IDS = new Set(['id', 'secondary', 'reqDay', 'costDay'])
|
||||
const toolbarHeaders = computed(() =>
|
||||
groupsTable.getHeaderGroups()[0].headers.filter((header) => TOOLBAR_COLUMN_IDS.has(header.column.id)),
|
||||
)
|
||||
const toolbarHeaders = computed(() => groupsTable.getHeaderGroups()[0].headers.filter((header) => TOOLBAR_COLUMN_IDS.has(header.column.id)))
|
||||
|
||||
/**
|
||||
* Which groups are open — driven by the SAME tested lib/search-expand.ts
|
||||
* state ProvidersView.vue uses for providers (generalized from
|
||||
* provider-expand.ts specifically so this view could reuse it, rather
|
||||
* than fork a second copy — see that module's own doc comment). The
|
||||
* adapter shape is the identical get/set computed pattern
|
||||
* expandedProviderValues uses there, translating the Set-based effective
|
||||
* state into the string[] Accordion's `type="multiple"` v-model expects.
|
||||
*/
|
||||
/** Which groups are open — driven by the shared tested lib/search-expand.ts state. */
|
||||
const groupExpandState: ExpandState = reactive({
|
||||
manuallyExpanded: new Set<string>(),
|
||||
manuallyCollapsed: new Set<string>(),
|
||||
})
|
||||
const expandedGroups = computed<Set<string>>(() =>
|
||||
computeExpandedItems(groupExpandState, hasQuery.value, memberOnlyMatchIds.value),
|
||||
)
|
||||
const expandedGroups = computed<Set<string>>(() => computeExpandedItems(groupExpandState, hasQuery.value, memberOnlyMatchIds.value))
|
||||
function toggleGroup(id: string): void {
|
||||
toggleItemExpand(groupExpandState, id, expandedGroups.value.has(id))
|
||||
}
|
||||
@@ -249,74 +188,26 @@ const expandedGroupValues = computed<string[]>({
|
||||
|
||||
// A stale manuallyCollapsed suppression from one search must never
|
||||
// silently carry into a later, unrelated one — see ExpandState's own doc
|
||||
// comment. Watching userQuery (not just the clear button) covers
|
||||
// backspacing to empty too — same convention as ProvidersView.vue.
|
||||
// comment.
|
||||
watch(userQuery, (value) => {
|
||||
if (value.trim() === '') clearExpandOverrides(groupExpandState)
|
||||
})
|
||||
|
||||
// --- Access block (group-access-display task, review round) ---
|
||||
//
|
||||
// GLOB_HINT is the title attribute on every Access label (Providers,
|
||||
// Models, MCP servers, Agents): group.allowsX (auth.go) matches every one
|
||||
// of these four configured lists via matchesGlob/path.Match semantics, not
|
||||
// literal equality — a heading that just says "Models" leaves that
|
||||
// invisible. One constant, not four copies of the same string (vue.md:
|
||||
// "if you've written it twice, you owe an abstraction").
|
||||
// --- Access block (moved from UsageView.vue, unchanged) ---
|
||||
const GLOB_HINT = 'Glob pattern — matches like a shell wildcard (*, ?, [...])'
|
||||
|
||||
/**
|
||||
* isGlobPattern reports whether one configured models entry is a glob
|
||||
* pattern rather than a literal model id — the same special characters
|
||||
* path.Match recognizes (auth.go's matchesGlob, which group.allowsModel
|
||||
* calls). Only Models needs this distinction rendered differently: a
|
||||
* literal id gets the copyable ModelChip treatment (it IS a routable id a
|
||||
* caller can paste into a request's `model` field); a pattern like
|
||||
* "gpt-4*" is not a valid id and must not invite copying it as one, so it
|
||||
* renders as a plain, non-copyable Badge instead (see the template below).
|
||||
* Providers/MCPServers/Agents render every entry as a plain Badge either
|
||||
* way, so they need no such split.
|
||||
*/
|
||||
/** isGlobPattern reports whether one configured models entry is a glob pattern rather than a literal model id — the same special characters path.Match recognizes (auth.go's matchesGlob). */
|
||||
function isGlobPattern(entry: string): boolean {
|
||||
return /[*?[]/.test(entry)
|
||||
}
|
||||
|
||||
// --- F1 usage-vs-limit bars (group detail grid) ---
|
||||
//
|
||||
// usage-columns.ts's own numericColumn already renders a bar beside each
|
||||
// TABLE cell that has a configured limit (Users table, and — reused — the
|
||||
// Members table inside an expanded group). A group's own accordion detail
|
||||
// grid (the dl/dd block below, distinct from its nested Members table) has
|
||||
// no per-cell numeric columns at all, so it renders budgetRatios directly
|
||||
// as one compact row of labeled bars instead — the coordinator brief's own
|
||||
// phrasing ("Group detail grid ... renders bars from budgetRatios").
|
||||
|
||||
// BUDGET_RATIO_LABEL (P10/P11 review fix): imported from lib/usage-bars.ts
|
||||
// — this used to be a second, independently-declared copy here (BUDGET_LABEL)
|
||||
// with slightly different text ("tok/day" here vs usage-columns.ts's own
|
||||
// bar label), the exact DRY drift the coordinator's review flagged. The one
|
||||
// shared definition also spells out "tokens (in+out)/day" (P10) for the
|
||||
// tokDay/tokMonth ids — accurate for a screen-reader user, who has no OTHER
|
||||
// column beside this compact grid's own labeled bar to infer the combined-
|
||||
// total measurement from.
|
||||
|
||||
/** budgetValueText renders one BudgetRatio's "used / limit" detail text (UsageBar's aria-valuetext/title) — cost ids in dollars (formatCost, the micro-USD unit budgetRatios already computed them in), every other id as a plain thousands-separated count. */
|
||||
/** 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 (F7) mirrors usage-columns.ts's own costMonthProjColumn
|
||||
* math for one group row — the coordinator brief's own phrasing ("also in
|
||||
* group grid"): the group detail dl/dd block is not a DataTable column, so
|
||||
* it cannot reuse that column's ColumnDef cell renderer directly, but the
|
||||
* underlying lib/forecast.ts math (monthProgress/projectMonthEnd/headroom)
|
||||
* is identical. `now` is the shared clock (useNow, above) — the SAME
|
||||
* instant every other time-dependent read on this tab uses — not a fresh
|
||||
* `new Date()` read per call, so this grid's own projection never disagrees
|
||||
* with CostForecastCard's or a column's mid-render.
|
||||
*/
|
||||
/** groupCostProjection mirrors usage-columns.ts's own costMonthProjColumn math for one group row. */
|
||||
function groupCostProjection(entry: AdminUsageEntryView, now: Date): { text: string; willExceed: boolean } {
|
||||
const elapsedFraction = monthProgress(now)
|
||||
const projected = projectMonthEnd(entry.costPerMonthMicroUsd, elapsedFraction)
|
||||
@@ -324,22 +215,8 @@ function groupCostProjection(entry: AdminUsageEntryView, now: Date): { text: str
|
||||
return { text: formatCost(projected), willExceed: headroom(projected, entry.limits?.costPerMonthUSD).willExceed }
|
||||
}
|
||||
|
||||
// --- F8 CSV export ---
|
||||
//
|
||||
// Each button exports whatever rows are CURRENTLY visible: Groups export
|
||||
// in the Groups toolbar's own sort order (sortedGroupRows, already
|
||||
// computed below for the headless sort toolbar); Users export in the
|
||||
// Users <table>'s own CURRENT column sort (P6 review fix — this used to
|
||||
// export in filtered order alone, a self-declared deviation from the plan's
|
||||
// "exports filtered+sorted rows"). The Users DataTable's sort state is
|
||||
// LIFTED UP here via DataTable.vue's `v-model:sorting` (that component's
|
||||
// own doc comment) — usersSorting below is bound two ways: down into the
|
||||
// rendered UsageTable (template) so a header click writes back here, and
|
||||
// into a second, headless useVueTable instance (usersTable) that applies
|
||||
// the identical sort to filteredUsers so usersCsv can read it back in the
|
||||
// same order the reader is actually looking at — the same "one sort state,
|
||||
// two consumers" shape sortedGroupRows/groupsTable already used for Groups.
|
||||
const usersColumns = computed(() => usageColumns('Name', 'Group', groupNameOf, now.value))
|
||||
// --- CSV export ---
|
||||
const usersColumns = computed(() => usageColumns('Name', 'Group', groupNameOf, now.value, sourceOf))
|
||||
const usersSorting = ref<SortingState>([])
|
||||
const usersTable = useVueTable({
|
||||
get data() {
|
||||
@@ -391,7 +268,6 @@ function groupsCsv(): string {
|
||||
<p class="text-lg font-semibold tabular-nums"><CompactNumber :value="usage.total.requestsPerDay" /></p>
|
||||
</div>
|
||||
<div>
|
||||
<!-- F4: fleet-wide requests denied by a limit check today (types/api.ts's own doc comment) — destructive tint once positive, mirroring usage-columns.ts's own rejDay column convention. -->
|
||||
<p class="text-muted-foreground">Rejected/day</p>
|
||||
<p class="text-lg font-semibold tabular-nums" :class="usage.total.rejectionsPerDay > 0 ? 'text-destructive' : undefined">
|
||||
<CompactNumber :value="usage.total.rejectionsPerDay" />
|
||||
@@ -412,7 +288,6 @@ function groupsCsv(): string {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- F7: fleet-wide cost forecast, directly under the Total card (coordinator brief). -->
|
||||
<CostForecastCard v-if="usage" :mtd-micros="usage.total.costPerMonthMicroUsd" :limits="usage.total.limits" :now="now" />
|
||||
|
||||
<Card>
|
||||
@@ -422,39 +297,18 @@ function groupsCsv(): string {
|
||||
<CardTitle>Groups</CardTitle>
|
||||
<CardDescription>Expand a group to see its member users' own usage.</CardDescription>
|
||||
</div>
|
||||
<CsvExportButton filename="usage-groups.csv" :build="groupsCsv" />
|
||||
<CsvExportButton filename="consumers-groups.csv" :build="groupsCsv" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
v-if="sortedGroupRows.length"
|
||||
class="mb-3 flex flex-wrap gap-x-4 gap-y-1 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
<div v-if="sortedGroupRows.length" class="mb-3 flex flex-wrap gap-x-4 gap-y-1 text-xs font-medium text-muted-foreground">
|
||||
<SortHeaderButton v-for="header in toolbarHeaders" :key="header.id" :header="header" />
|
||||
</div>
|
||||
|
||||
<Accordion v-if="sortedGroupRows.length" v-model="expandedGroupValues" type="multiple" class="rounded-md border px-3">
|
||||
<AccordionItem v-for="row in sortedGroupRows" :key="row.original.id" :value="row.original.id">
|
||||
<!--
|
||||
P2 review fix: ScopeLink moved OUTSIDE AccordionTrigger — a
|
||||
real interactive element can never be a valid descendant of
|
||||
another interactive element (AccordionTrigger's own <button>,
|
||||
reka-ui), so it is now a sibling in this header row instead
|
||||
of nested inside the trigger's own slot. `py-2.5` matches
|
||||
AccordionTrigger.vue's own vertical padding so the two line
|
||||
up, keeping the same visual row the nested version had.
|
||||
`header-class="flex-1"` fills the AccordionHeader (<h3>) that
|
||||
wraps the trigger button, since AccordionTrigger's own
|
||||
`class` prop lands on the inner button, not the header — see
|
||||
AccordionTrigger.vue's doc comment. Without it the header
|
||||
shrinks to content width, so the chevron drifts left of the
|
||||
row's right edge and the empty space to its right stops
|
||||
being clickable. The sr-only span gives the toggle button an
|
||||
accessible name that names the group, since the visible text
|
||||
beside it is just counts.
|
||||
-->
|
||||
<div class="flex items-center gap-2">
|
||||
<ScopeLink class="shrink-0 py-2.5 font-medium" :label="row.original.id" :scope="`group:${row.original.id}`" />
|
||||
<span class="shrink-0 py-2.5 font-medium">{{ row.original.id }}</span>
|
||||
<AccordionTrigger header-class="flex-1">
|
||||
<span class="sr-only">Group {{ row.original.id }} details</span>
|
||||
<span class="flex flex-1 flex-wrap items-center gap-x-4 gap-y-1 pr-2 text-left">
|
||||
@@ -470,16 +324,6 @@ function groupsCsv(): string {
|
||||
</AccordionTrigger>
|
||||
</div>
|
||||
<AccordionContent>
|
||||
<!--
|
||||
Access block (group-access-display task): the group's
|
||||
configured provider/model/MCP-server/agent access,
|
||||
display-only — not part of the user/group search filter
|
||||
above (row.original.providers/models/mcpServers/agents are
|
||||
plain display data, never touched by groupMatches/
|
||||
userMatches in lib/usage-search.ts). Same dl/dt/dd
|
||||
convention as the stat grid immediately below — one
|
||||
convention per panel.
|
||||
-->
|
||||
<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>
|
||||
@@ -531,13 +375,7 @@ function groupsCsv(): string {
|
||||
<dt class="text-xs text-muted-foreground" :title="GLOB_HINT">Agents</dt>
|
||||
<dd>
|
||||
<div v-if="row.original.agents?.length" class="flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
v-for="agentName in row.original.agents"
|
||||
:key="agentName"
|
||||
as="span"
|
||||
variant="secondary"
|
||||
class="font-mono font-normal"
|
||||
>
|
||||
<Badge v-for="agentName in row.original.agents" :key="agentName" as="span" variant="secondary" class="font-mono font-normal">
|
||||
{{ agentName }}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -597,7 +435,6 @@ function groupsCsv(): string {
|
||||
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : formatCost(row.original.costPerMonthMicroUsd) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<!-- F7: projected month-end cost, extrapolated from this group's own month-to-date spend (mirrors usage-columns.ts's costMonthProj column). -->
|
||||
<dt class="text-xs text-muted-foreground">cost/month (proj.)</dt>
|
||||
<dd
|
||||
class="tabular-nums"
|
||||
@@ -608,7 +445,6 @@ function groupsCsv(): string {
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<!-- F1: usage-vs-limit bars, one per configured limit — budgetRatios (lib/usage-bars.ts) returns nothing for an unlimited group or a storeDown one, so this whole row is simply absent then. -->
|
||||
<div v-if="budgetRatios(row.original).length" class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||
<span
|
||||
v-for="ratio in budgetRatios(row.original)"
|
||||
@@ -626,6 +462,7 @@ function groupsCsv(): string {
|
||||
:entries="visibleMembers(row.original)"
|
||||
:secondary-value="groupNameOf"
|
||||
:now="now"
|
||||
:source-value="sourceOf"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -637,8 +474,11 @@ function groupsCsv(): string {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<CardTitle>Users</CardTitle>
|
||||
<CsvExportButton filename="usage-users.csv" :build="usersCsv" />
|
||||
<div>
|
||||
<CardTitle>Users</CardTitle>
|
||||
<CardDescription v-if="consumers.data?.usersFile">File-backed users load from {{ consumers.data.usersFile }}.</CardDescription>
|
||||
</div>
|
||||
<CsvExportButton filename="consumers-users.csv" :build="usersCsv" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -649,6 +489,7 @@ function groupsCsv(): string {
|
||||
:secondary-value="groupNameOf"
|
||||
:empty-message="usersEmptyMessage"
|
||||
:now="now"
|
||||
:source-value="sourceOf"
|
||||
v-model:sorting="usersSorting"
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from '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'
|
||||
|
||||
/**
|
||||
* CostAvoidedCard (redesign-plan.md section 3.4) reports what the fleet's
|
||||
* free-tier traffic in range WOULD have cost against a real, priced
|
||||
* reference model — lib/cost-avoided.ts's own doc comment has the unit
|
||||
* derivation. The reference model defaults to the most-requested priced
|
||||
* model actually served in range (pickReferenceModel) but is always
|
||||
* reader-overridable via the picker below; `ref` is a CONTROLLED prop
|
||||
* (this card owns no picker state itself), mirroring DataTable.vue's own
|
||||
* `sorting`/`update:sorting` convention — SpendPage.vue keeps the current
|
||||
* choice in the `ref` page param (redesign-plan.md section 3.1) so a
|
||||
* reload or shared link restores the same comparison.
|
||||
*
|
||||
* Cache savings (redesign-plan.md section 3.4: "cache savings (sum csave)
|
||||
* shown alongside when enabled") render as a second figure beside the
|
||||
* cost-avoided one, only when `cacheStatsEnabled` — a genuinely different
|
||||
* saving (a repeat request served from cache, never re-billed at all) that
|
||||
* would otherwise be easy to conflate with "served by a free model".
|
||||
*
|
||||
* N2 fix (verify-redesign-final.md): the figure is not always summable.
|
||||
* `cacheSavingsAvailable` (lib/cache-savings.ts's own doc comment has the
|
||||
* full backend grounding) is false for an hour-window range — the
|
||||
* backend never writes a per-hour cache-savings bucket at all, so
|
||||
* `m.cacheSavedMicroUsd` is undefined on every row and summing it would
|
||||
* silently render a fabricated "$0.00" rather than "no data for this
|
||||
* range". The card renders "n/a" instead whenever it is false.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** The model ranking (GET /admin/api/usage/models, detail=1) — every free row feeds costAvoidedMicros; every catalog-priced row is a candidate reference model. */
|
||||
models: AdminUsageModelEntry[]
|
||||
catalog: Map<string, AdminCatalogModel>
|
||||
/**
|
||||
* The reader's explicit reference-model override (the `ref` page param),
|
||||
* or '' for "use the default". Named `refModel`, not `ref` — `ref` is a
|
||||
* reserved Vue template attribute (component template refs); a prop
|
||||
* literally named `ref` can never be set from a parent's template via
|
||||
* `:ref="..."`, since Vue always intercepts that binding for its own
|
||||
* template-ref mechanism instead of passing it through as a prop.
|
||||
*/
|
||||
refModel: string
|
||||
/** AdminFeaturesView.cacheStats — gates the cache-savings figure. */
|
||||
cacheStatsEnabled: boolean
|
||||
/**
|
||||
* cacheSavingsAvailable (N2 fix, lib/cache-savings.ts's own
|
||||
* cacheSavingsStatus) — false for an hour-window range, where the
|
||||
* backend has no per-hour cache-savings bucket at all. The card
|
||||
* renders "n/a" instead of a figure when this is false, never a
|
||||
* fabricated "$0.00". SpendPage.vue computes this from the current
|
||||
* global range (it owns the filters store; this card stays a pure,
|
||||
* props-driven component).
|
||||
*/
|
||||
cacheSavingsAvailable: boolean
|
||||
/**
|
||||
* cacheSavingsNote (N2 fix) — a caveat to show ALONGSIDE an available
|
||||
* figure (currently only the month-window "35-day retention" note,
|
||||
* lib/cache-savings.ts), or the reason text when
|
||||
* `cacheSavingsAvailable` is false. Null when the figure is available
|
||||
* and exact (day-window ranges).
|
||||
*/
|
||||
cacheSavingsNote?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:refModel': [value: string] }>()
|
||||
|
||||
/** priceableOptions is the picker's own list: every served model whose catalog entry is genuinely priced (excludes free/unpriced — comparing free traffic against another $0 model is meaningless, the same filter pickReferenceModel itself applies), sorted by descending requests so the most relevant choices sit at the top. */
|
||||
const priceableOptions = computed<{ id: string; requests: number }[]>(() => {
|
||||
const out: { id: string; requests: number }[] = []
|
||||
for (const m of props.models) {
|
||||
const cat = props.catalog.get(m.id)
|
||||
if (!cat || cat.priceSource === 'free' || cat.priceSource === 'unpriced') continue
|
||||
out.push({ id: m.id, requests: m.requests ?? 0 })
|
||||
}
|
||||
return out.sort((a, b) => b.requests - a.requests)
|
||||
})
|
||||
|
||||
const defaultReference = computed<string | null>(() => pickReferenceModel(props.models, props.catalog))
|
||||
const effectiveReference = computed<string | null>(() => props.refModel || defaultReference.value)
|
||||
const referenceCatalog = computed<AdminCatalogModel | undefined>(() =>
|
||||
effectiveReference.value ? props.catalog.get(effectiveReference.value) : undefined,
|
||||
)
|
||||
|
||||
const avoidedMicros = computed<number | null>(() => {
|
||||
const cat = referenceCatalog.value
|
||||
if (!cat || cat.inputPerMTokUsd === undefined || cat.outputPerMTokUsd === undefined) return null
|
||||
return costAvoidedMicros(freeRows(props.models), cat.inputPerMTokUsd, cat.outputPerMTokUsd)
|
||||
})
|
||||
|
||||
const cacheSavedMicros = computed<number>(() => {
|
||||
let total = 0
|
||||
for (const m of props.models) total += m.cacheSavedMicroUsd ?? 0
|
||||
return total
|
||||
})
|
||||
|
||||
function onReferenceUpdate(value: unknown): void {
|
||||
if (typeof value === 'string') emit('update:refModel', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Cost avoided (free-tier traffic)</p>
|
||||
<p v-if="avoidedMicros === null" class="text-sm text-muted-foreground">
|
||||
{{ priceableOptions.length === 0 ? 'No priced model served in range to compare against.' : 'Pick a reference model above.' }}
|
||||
</p>
|
||||
<p v-else class="text-lg font-semibold tabular-nums text-chart-requests">{{ formatCost(avoidedMicros) }}</p>
|
||||
</div>
|
||||
<div v-if="cacheStatsEnabled">
|
||||
<p class="text-muted-foreground">Cache savings</p>
|
||||
<p v-if="!cacheSavingsAvailable" class="text-lg font-semibold tabular-nums text-muted-foreground">n/a</p>
|
||||
<p v-else class="text-lg font-semibold tabular-nums text-chart-requests">{{ formatCost(cacheSavedMicros) }}</p>
|
||||
<p v-if="cacheSavingsNote" class="text-xs text-status-warn">{{ cacheSavingsNote }}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -7,27 +7,30 @@ import { formatCost } from '@/lib/format'
|
||||
import type { LimitsConfig } from '@/types/api'
|
||||
|
||||
/**
|
||||
* CostForecastCard renders the Usage tab's fleet-wide cost projection —
|
||||
* F7, placed under the Total card (UsageView.vue). All math is delegated
|
||||
* to lib/forecast.ts's pure functions; this component only formats and
|
||||
* lays the result out.
|
||||
* CostForecastCard renders a fleet- or scope-wide cost projection — F7,
|
||||
* originally the pre-redesign Usage tab's own card, now used by both
|
||||
* SpendPage.vue (the global range's own scope, under the burn-down chart)
|
||||
* and ConsumerDirectory.vue (fleet-wide, under the Total row). All math is
|
||||
* delegated to lib/forecast.ts's pure functions; this component only
|
||||
* formats and lays the result out.
|
||||
*
|
||||
* `now` is a REQUIRED prop, not read from `new Date()` inside this
|
||||
* component (coordinator brief: "now passed as prop for determinism") —
|
||||
* the caller (UsageView.vue) supplies it, keeping every `Date.now()`-
|
||||
* flavored read in this feature at the boundary rather than buried inside
|
||||
* a component, same discipline lib/forecast.ts's own functions already
|
||||
* follow by taking `now`/`elapsedFraction` as plain arguments.
|
||||
* every caller supplies it from its OWN composables/useNow.ts instance,
|
||||
* keeping every `Date.now()`-flavored read in this feature at the
|
||||
* boundary rather than buried inside a component, same discipline
|
||||
* lib/forecast.ts's own functions already follow by taking
|
||||
* `now`/`elapsedFraction` as plain arguments.
|
||||
*
|
||||
* P1 review fix: this used to be optional with a `withDefaults(...,
|
||||
* { now: () => new Date() })` default. Vue caches a prop's default value
|
||||
* ONCE per component instance (propsDefaults) — the very first time it is
|
||||
* read, not on every render — so a caller that omitted `now` froze this
|
||||
* card's projection at whatever instant it happened to mount, never
|
||||
* advancing again for as long as the tab stayed open. `now` is now
|
||||
* required: UsageView.vue passes the ONE shared reactive clock
|
||||
* (composables/useNow.ts) every time-dependent read on this tab shares,
|
||||
* so there is no default left to accidentally freeze.
|
||||
* advancing again for as long as the page stayed open. `now` is now
|
||||
* required: every caller passes the ONE shared reactive clock its own
|
||||
* useNow() call returns, so there is no default left to accidentally
|
||||
* freeze.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** usage.total.costPerMonthMicroUsd — fleet-wide month-to-date spend. */
|
||||
|
||||
@@ -7,11 +7,11 @@ import { downloadText, type DownloadOutcome } from '@/lib/download'
|
||||
|
||||
/**
|
||||
* CsvExportButton is the one "Export CSV" control every table export in
|
||||
* this panel uses — F8. Owned by WP-B1 (usage-csv.ts, this component) but
|
||||
* consumed unmodified by WP-B2's Models tab, which builds its own CSV text
|
||||
* via lib/usage-csv.ts's modelDetailCsv and passes it through the same
|
||||
* `build` prop — one export affordance, not a copy per tab (vue.md: "if
|
||||
* you've written it twice, you owe an abstraction").
|
||||
* this panel uses (F8) — ConsumerDirectory.vue's Users/Groups exports
|
||||
* (lib/usage-csv.ts's usageCsv) and PricingHealthTable.vue's own
|
||||
* pricing-health export each build their own CSV text and pass it
|
||||
* through the same `build` prop — one export affordance, not a copy per
|
||||
* table (vue.md: "if you've written it twice, you owe an abstraction").
|
||||
*
|
||||
* `build` is called fresh on every click, never memoized — an export
|
||||
* always reflects whatever rows are CURRENTLY visible (filtered + sorted)
|
||||
|
||||
@@ -43,8 +43,8 @@ const props = defineProps<{
|
||||
* default) keeps every existing call site's original UNCONTROLLED
|
||||
* behavior unchanged — this component owns `internalSorting` itself and
|
||||
* a caller has no way to read the current sort. Passed, a caller (e.g.
|
||||
* UsageView.vue's Users export, which needs to export rows in the
|
||||
* table's own current sort order — the plan's own "exports
|
||||
* ConsumerDirectory.vue's Users export, which needs to export rows in
|
||||
* the table's own current sort order — the plan's own "exports
|
||||
* filtered+sorted rows" requirement) instead owns the ref: every header
|
||||
* click emits `update:sorting` with the new state rather than mutating
|
||||
* anything locally here.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
|
||||
/**
|
||||
* EntityLink (redesign-plan.md section 3.5) is the redesigned shell's own
|
||||
* navigation link for an entity id: a user id opens Consumers pre-filtered
|
||||
* to that user (lib/usage-columns.ts's id cell, UserDetail.vue), a model
|
||||
* id opens Models pre-filtered to that model (lib/model-table-columns.ts's
|
||||
* id cell) — nav.goTo rather than a bare href, so it stays inside the
|
||||
* SPA's own hash-routed shell. It replaced ScopeLink.vue (deleted, section
|
||||
* 3.5) once every one of that component's own call sites had switched
|
||||
* over.
|
||||
*
|
||||
* Structural note carried over from ScopeLink.vue: a real
|
||||
* `<button type="button">`, always rendered as a SIBLING of whatever
|
||||
* interactive element it sits beside (a table cell, an accordion header),
|
||||
* never nested inside one — the HTML button content model forbids a
|
||||
* focusable descendant, and nesting one breaks both keyboard operation
|
||||
* and how assistive technology exposes it.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** The visible + accessible label, e.g. a user id or model id. */
|
||||
label: string
|
||||
/** Which page this id opens — 'user' -> Consumers, 'model' -> Models. */
|
||||
kind: 'user' | 'model'
|
||||
/** The raw entity id (never pre-formatted as "kind:id" — this component builds nav.goTo's own params itself). */
|
||||
id: string
|
||||
}>()
|
||||
|
||||
const nav = useNavStore()
|
||||
|
||||
const TARGET: Record<'user' | 'model', { page: 'consumers' | 'models'; paramKey: 'user' | 'model'; noun: string }> = {
|
||||
user: { page: 'consumers', paramKey: 'user', noun: 'Consumers' },
|
||||
model: { page: 'models', paramKey: 'model', noun: 'Models' },
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent): void {
|
||||
event.stopPropagation()
|
||||
const target = TARGET[props.kind]
|
||||
nav.goTo(target.page, { [target.paramKey]: props.id })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="`View ${label} in ${TARGET[kind].noun}`"
|
||||
:title="`View ${label} in ${TARGET[kind].noun}`"
|
||||
class="inline-flex cursor-pointer items-center gap-1 rounded-sm text-inherit hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
@click="onClick"
|
||||
>
|
||||
<span>{{ label }}</span>
|
||||
<FontAwesomeIcon :icon="faArrowUpRightFromSquare" class="size-3" aria-hidden="true" />
|
||||
</button>
|
||||
</template>
|
||||
@@ -25,8 +25,9 @@ const ALL_KINDS = 'all'
|
||||
// kindFilter/userFilter live on the store, not as local refs (F9,
|
||||
// hash-state): composables/useHashState.ts reads/writes them directly so
|
||||
// this filter round-trips through the URL hash. userFilter binds through
|
||||
// useSearchQuery's own external-ref option, the same way UsageView.vue
|
||||
// binds nav.usageQuery — see useSearchQuery.ts's own doc comment.
|
||||
// useSearchQuery's own external-ref option — see useSearchQuery.ts's own
|
||||
// doc comment for the other current external-ref caller
|
||||
// (ConsumerDirectory.vue).
|
||||
const { kindFilter, userFilter } = storeToRefs(events)
|
||||
const { normalized: normalizedUserQuery } = useSearchQuery(userFilter)
|
||||
|
||||
@@ -48,9 +49,9 @@ function onKindChange(value: unknown): void {
|
||||
const columns = eventsColumns()
|
||||
|
||||
/**
|
||||
* emptyMessage mirrors TargetsView.vue/UsageView.vue's own three-way
|
||||
* pattern: distinguishes "nothing recorded at all" from "nothing matches
|
||||
* the active filter".
|
||||
* emptyMessage mirrors TargetsView.vue/ConsumerDirectory.vue's own
|
||||
* three-way pattern: distinguishes "nothing recorded at all" from
|
||||
* "nothing matches the active filter".
|
||||
*
|
||||
* P9 review fix: gated on events.lastUpdated (null until the first fetch
|
||||
* settles, stores/events.ts) — this used to read events.events.length
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { faArrowRightArrowLeft } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, ref, watch } from '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'
|
||||
import { DEFAULT_SCOPE, useFiltersStore } from '@/stores/filters'
|
||||
|
||||
/** RANGE_LABEL is the range Select's display text per lib/range.ts's RangeKey — kept here (not lib/range.ts) since it is pure presentation, not logic a store or another page's fetch needs. */
|
||||
const RANGE_LABEL: Record<RangeKey, string> = {
|
||||
'24h': 'Last 24 hours',
|
||||
'48h': 'Last 48 hours',
|
||||
'7d': 'Last 7 days',
|
||||
'14d': 'Last 14 days',
|
||||
'30d': 'Last 30 days',
|
||||
'3mo': 'Last 3 months',
|
||||
'6mo': 'Last 6 months',
|
||||
'12mo': 'Last 12 months',
|
||||
}
|
||||
|
||||
/**
|
||||
* GlobalFilterBar (redesign-plan.md section 3.1) is the shell's one
|
||||
* control surface for the three global filters (stores/filters.ts):
|
||||
* time range, "vs previous period" comparison, and an optional
|
||||
* group/user/provider scope narrowing. Every page reads the SAME filters
|
||||
* store; this bar is mounted once, in App.vue's header, not per-page.
|
||||
*/
|
||||
const filters = useFiltersStore()
|
||||
|
||||
function onRangeUpdate(value: unknown): void {
|
||||
if (typeof value !== 'string') return
|
||||
filters.setFilters({ range: value as RangeKey })
|
||||
}
|
||||
|
||||
/**
|
||||
* toggleCmp (P3 item 19): turning the comparison OFF is always allowed,
|
||||
* even when the CURRENT range does not support one — a `cmp=prev` value
|
||||
* restored from the hash (e.g. loaded directly onto a 48h/30d/12mo range,
|
||||
* where 2*span exceeds the retention ceiling) used to leave the button
|
||||
* permanently pressed-and-disabled, with no way to turn it off short of
|
||||
* changing the range first. filters.cmpSpec.requested already gates the
|
||||
* actual fetch on availability (stores/filters.ts's own doc comment), so
|
||||
* this was purely a stuck AFFORDANCE, never a bad request. Turning it ON
|
||||
* still requires availability — there is nothing to compare against
|
||||
* otherwise.
|
||||
*/
|
||||
function toggleCmp(): void {
|
||||
if (filters.cmp === 'prev') {
|
||||
filters.setFilters({ cmp: 'none' })
|
||||
return
|
||||
}
|
||||
if (!filters.cmpSpec.available) return
|
||||
filters.setFilters({ cmp: 'prev' })
|
||||
}
|
||||
|
||||
/** cmpDisabled only blocks turning the toggle ON when unavailable — turning an already-on comparison OFF is never blocked (toggleCmp's own doc comment). */
|
||||
const cmpDisabled = computed<boolean>(() => filters.cmp !== 'prev' && !filters.cmpSpec.available)
|
||||
|
||||
const cmpTitle = computed<string>(() => {
|
||||
if (filters.cmp === 'prev' && !filters.cmpSpec.available) {
|
||||
return `Comparison unavailable for this range (${filters.cmpSpec.reason}) — click to turn off`
|
||||
}
|
||||
if (filters.cmpSpec.available) return 'Compare against the previous equal-length period'
|
||||
return `Comparison unavailable for this range: ${filters.cmpSpec.reason}`
|
||||
})
|
||||
|
||||
// scopeDraft is a local editing buffer, not bound straight to
|
||||
// filters.scope: a partially-typed value ("group:") must not spam
|
||||
// setFilters/refetch every store on every keystroke, and an invalid
|
||||
// commit (onBlur/Enter, below) must revert the FIELD, not the store —
|
||||
// the store keeps whatever scope was last valid.
|
||||
const scopeDraft = ref(filters.scope)
|
||||
watch(
|
||||
() => filters.scope,
|
||||
(scope) => {
|
||||
scopeDraft.value = scope
|
||||
},
|
||||
)
|
||||
|
||||
function commitScope(): void {
|
||||
const value = scopeDraft.value.trim() || DEFAULT_SCOPE
|
||||
if (SCOPE_PATTERN.test(value)) {
|
||||
filters.setFilters({ scope: value })
|
||||
} else {
|
||||
// Invalid — revert the field to the store's own current value rather than keeping the bad text on screen.
|
||||
scopeDraft.value = filters.scope
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:aria-pressed="filters.cmp === 'prev'"
|
||||
:disabled="cmpDisabled"
|
||||
:title="cmpTitle"
|
||||
@click="toggleCmp"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faArrowRightArrowLeft" class="size-3.5" aria-hidden="true" />
|
||||
vs previous period
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="global-filter-scope" class="text-xs font-medium text-muted-foreground">Scope</label>
|
||||
<Input
|
||||
id="global-filter-scope"
|
||||
v-model="scopeDraft"
|
||||
class="w-48"
|
||||
placeholder="all"
|
||||
title="all, group:<name>, user:<name>, or provider:<name>"
|
||||
@keyup.enter="commitScope"
|
||||
@blur="commitScope"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import type { KpiTier } from '@/lib/kpi'
|
||||
import type { PageId } from '@/lib/pages'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
|
||||
/**
|
||||
* KpiTile (redesign-plan.md section 3.4) is the Home page's one KPI-tile
|
||||
* shape: a label, a headline value, an optional delta/detail line, and a
|
||||
* tier (lib/kpi.ts) that colors the value text — 'ok' (default
|
||||
* foreground), 'warn' (--status-warn), 'critical' (--destructive), the
|
||||
* same two semantic tokens the rest of this panel's badges/bars already
|
||||
* use, never a fourth ad-hoc color.
|
||||
*
|
||||
* Optionally a link: when `to` is set, the whole tile becomes a real
|
||||
* `<button>` that calls nav.goTo(to.page, to.params) — a budget tile
|
||||
* jumps to Spend, an open-breakers tile jumps to Reliability, and so on.
|
||||
* Without `to`, it renders as a plain, non-interactive Card (a tile with
|
||||
* nowhere useful to send the reader, e.g. "requests/min", stays inert
|
||||
* rather than a button that does nothing).
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
value: string
|
||||
delta?: string
|
||||
tier?: KpiTier
|
||||
to?: { page: PageId; params?: Record<string, string> }
|
||||
}>(),
|
||||
{ tier: 'ok' },
|
||||
)
|
||||
|
||||
const nav = useNavStore()
|
||||
|
||||
const valueClass = computed<string>(() => {
|
||||
switch (props.tier) {
|
||||
case 'warn':
|
||||
return 'text-status-warn'
|
||||
case 'critical':
|
||||
return 'text-destructive'
|
||||
default:
|
||||
return 'text-foreground'
|
||||
}
|
||||
})
|
||||
|
||||
function onClick(): void {
|
||||
if (!props.to) return
|
||||
nav.goTo(props.to.page, props.to.params ?? {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="to ? 'button' : 'div'"
|
||||
:type="to ? 'button' : undefined"
|
||||
class="block w-full rounded-xl text-left"
|
||||
:class="to ? 'cursor-pointer transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50' : undefined"
|
||||
@click="onClick"
|
||||
>
|
||||
<Card>
|
||||
<CardContent class="flex flex-col gap-1 px-4 py-3">
|
||||
<span class="text-xs font-medium text-muted-foreground">{{ label }}</span>
|
||||
<span class="text-2xl font-semibold tracking-tight tabular-nums" :class="valueClass">{{ value }}</span>
|
||||
<span v-if="delta" class="text-xs text-muted-foreground">{{ delta }}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { filterModelRows } from '@/lib/model-filter'
|
||||
import { modelCatalogColumns } from '@/lib/model-table-columns'
|
||||
import type { ModelCatalogRow } from '@/lib/model-table-columns'
|
||||
|
||||
/**
|
||||
* ModelCatalogTable (redesign-plan.md section 3.4, Models & providers
|
||||
* page) is the catalog-driven table: every configured model (not just
|
||||
* ones with observed traffic), joined with the current range's ranking
|
||||
* and fleet performance — ModelCatalogRow's own doc comment
|
||||
* (lib/model-table-columns.ts) has the full join contract.
|
||||
*
|
||||
* `query` is a controlled v-model, not owned locally: ModelsPage.vue keeps
|
||||
* it so the Models page's own `q` hash param (redesign-plan.md section
|
||||
* 3.1) round-trips through the URL, the same controlled-component
|
||||
* convention SearchInput.vue itself already documents.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
rows: ModelCatalogRow[]
|
||||
query: string
|
||||
/** GET /admin/api/performance's own latencyEnabled (AdminPerfResponse) — false means admin.stats.latency is off, so p50/p95 read "—" for every row, not "no traffic yet". */
|
||||
latencyEnabled: boolean
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:query': [value: string] }>()
|
||||
|
||||
const filtered = computed<ModelCatalogRow[]>(() => filterModelRows(props.rows, props.query))
|
||||
|
||||
const columns = modelCatalogColumns()
|
||||
|
||||
const emptyMessage = computed<string>(() => {
|
||||
if (props.loading) return 'loading…'
|
||||
if (props.rows.length === 0) return 'no models configured'
|
||||
if (props.query.trim()) return `no models match "${props.query}"`
|
||||
return 'none'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Model catalog</CardTitle>
|
||||
<CardDescription>
|
||||
Every configured model: price source, per-model usage for the current range, fleet p50/p95, and error rate.
|
||||
</CardDescription>
|
||||
<SearchInput
|
||||
:model-value="query"
|
||||
placeholder="Search models or aliases..."
|
||||
class="mt-2 max-w-sm"
|
||||
@update:model-value="emit('update:query', $event)"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<Alert v-if="!latencyEnabled" variant="warn">
|
||||
<AlertDescription>
|
||||
Latency percentiles (p50/p95) are off for this deployment. Enable <code>admin.stats.latency</code> to see them.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<DataTable :columns="columns" :data="filtered" :empty-message="emptyMessage" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -9,13 +9,15 @@ import { formatContextWindow, formatModelCostHover } from '@/lib/format'
|
||||
/**
|
||||
* ModelChip is one clickable, copyable model-id chip, used anywhere a
|
||||
* resolvable model or alias id renders: a provider's expanded model list
|
||||
* (ProvidersView.vue's accordion), the model-alias table, and a group's
|
||||
* usage-detail model list (UsageView.vue — passed with no metadata props
|
||||
* there, since a raw group-config glob entry has no resolvable provider/
|
||||
* model pair to look metadata up against). id is the full routable id
|
||||
* (lib/format.ts's routableModelId) or an alias name — what actually
|
||||
* gets copied, so a pasted value works straight into a `model: "..."`
|
||||
* request field.
|
||||
* and the model-alias table (both ProviderHealthPanel.vue's accordion),
|
||||
* the Models page's own catalog table (lib/model-table-columns.ts, WITH
|
||||
* metadata props — see contextTokens/inputPerMTokUsd/outputPerMTokUsd
|
||||
* below), and a group's own allowed-models list (ConsumerDirectory.vue —
|
||||
* passed with no metadata props there for a raw glob entry, since it has
|
||||
* no resolvable provider/model pair to look metadata up against). id is
|
||||
* the full routable id (lib/format.ts's routableModelId) or an alias
|
||||
* name — what actually gets copied, so a pasted value works straight
|
||||
* into a `model: "..."` request field.
|
||||
*
|
||||
* contextTokens/inputPerMTokUsd/outputPerMTokUsd are this model's
|
||||
* resolved metadata (feature v0.23, hover-detail refinement:
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import { computed } from 'vue'
|
||||
import { Bar } from 'vue-chartjs'
|
||||
|
||||
import '@/lib/chart-setup'
|
||||
import { useThemeColors } from '@/composables/useThemeColors'
|
||||
import { formatCompactCount, formatCost, formatExactInt } from '@/lib/format'
|
||||
import { MODEL_METRIC_LABEL, type ModelMetric } from '@/stores/history'
|
||||
import type { AdminUsageModelEntry } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
metric: ModelMetric
|
||||
models: AdminUsageModelEntry[]
|
||||
}>()
|
||||
|
||||
const { foreground, mutedForeground, border } = useThemeColors()
|
||||
|
||||
/**
|
||||
* One token per rankable metric, reusing the SAME --chart-* custom
|
||||
* properties the time-series chart uses (UsageChart.vue's DATASET_SPECS),
|
||||
* so a model's bar is the colour that metric already has elsewhere in the
|
||||
* view rather than a second, conflicting palette.
|
||||
*/
|
||||
const METRIC_COLOR: Record<ModelMetric, string> = {
|
||||
req: '--chart-requests',
|
||||
tokin: '--chart-tokens-in',
|
||||
tokout: '--chart-tokens-out',
|
||||
cost: '--chart-cost',
|
||||
}
|
||||
|
||||
/** Cost is stored as micro-USD (limits.go's account/history convention); every other metric is already the display unit. */
|
||||
function toDisplayValue(metric: ModelMetric, raw: number): number {
|
||||
return metric === 'cost' ? raw / 1_000_000 : raw
|
||||
}
|
||||
|
||||
/** resolveToken reads a --chart-* custom property from :root, live — same approach (and same reason) as UsageChart.vue. */
|
||||
function resolveToken(cssVar: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* The server already returns the ranking sorted, descending, and capped —
|
||||
* this component neither re-sorts nor re-filters it. Chart.js draws the
|
||||
* first category at the TOP of a horizontal bar chart, so the busiest
|
||||
* model lands where a reader looks first, with no reversal needed.
|
||||
*/
|
||||
const chartData = computed<ChartData<'bar'>>(() => {
|
||||
void foreground.value // re-run on a prefers-color-scheme flip (see UsageChart.vue)
|
||||
return {
|
||||
labels: props.models.map((m) => m.id),
|
||||
datasets: [
|
||||
{
|
||||
label: MODEL_METRIC_LABEL[props.metric],
|
||||
backgroundColor: resolveToken(METRIC_COLOR[props.metric]),
|
||||
data: props.models.map((m) => toDisplayValue(props.metric, m.value)),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Height grows with the row count so bars keep a readable thickness
|
||||
* instead of compressing into hairlines at 20 models, and so a two-model
|
||||
* ranking does not stretch each bar across a third of the screen.
|
||||
*/
|
||||
const chartHeight = computed<string>(() => `${Math.max(180, props.models.length * 28 + 60)}px`)
|
||||
|
||||
const chartOptions = computed<ChartOptions<'bar'>>(() => ({
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
scales: {
|
||||
x: {
|
||||
// Zero-based, honest axis: bar length is the whole comparison here.
|
||||
beginAtZero: true,
|
||||
grid: { color: border.value },
|
||||
ticks: {
|
||||
color: mutedForeground.value,
|
||||
// Reuses formatCost, same rationale as UsageChart.vue's identical
|
||||
// fix: a raw tick float interpolated directly into "$${value}" can
|
||||
// print unrounded binary-float noise; formatCost's .toFixed(4)
|
||||
// resolves it, and is the same formatter every other cost figure
|
||||
// in this app already renders through.
|
||||
callback: (value) => (props.metric === 'cost' ? formatCost(Number(value) * 1_000_000) : formatCompactCount(Number(value))),
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { display: false },
|
||||
ticks: { color: mutedForeground.value, autoSkip: false },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
// Compact figure plus the exact one, matching UsageChart.vue's
|
||||
// tooltip contract — a tooltip has room for both, and precision
|
||||
// must never be lost to the axis's compact form.
|
||||
label: (ctx) => {
|
||||
const value = ctx.parsed.x ?? 0
|
||||
if (props.metric === 'cost') return `${MODEL_METRIC_LABEL[props.metric]}: $${value.toFixed(4)}`
|
||||
return `${MODEL_METRIC_LABEL[props.metric]}: ${formatCompactCount(value)} (${formatExactInt(value)})`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p v-if="models.length === 0" class="py-8 text-center text-sm text-muted-foreground">
|
||||
No model recorded any usage in this window.
|
||||
</p>
|
||||
<div v-else class="w-full" :style="{ height: chartHeight }">
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
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 EntityLink from '@/components/EntityLink.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { toCsv } from '@/lib/csv'
|
||||
import { formatContextWindow, formatCost, formatModelCostHover } from '@/lib/format'
|
||||
import { joinPricingHealth, pricingHealthOrder } from '@/lib/pricing-health'
|
||||
import type { PricingHealthRow } from '@/lib/pricing-health'
|
||||
import type { AdminCatalogModel, AdminUsageModelEntry, PriceSource } from '@/types/api'
|
||||
|
||||
/**
|
||||
* PricingHealthTable (redesign-plan.md section 3.4) lists every model
|
||||
* actually served in the current range, joined with its catalog entry
|
||||
* (lib/pricing-health.ts's joinPricingHealth), unpriced first with a
|
||||
* destructive badge (pricingHealthOrder) — the row an operator most needs
|
||||
* to act on (a served model billing at $0 because no pricing rule
|
||||
* matched) is always the first thing they see, before any manual sort.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
models: AdminUsageModelEntry[]
|
||||
catalog: Map<string, AdminCatalogModel>
|
||||
}>()
|
||||
|
||||
const rows = computed<PricingHealthRow[]>(() => pricingHealthOrder(joinPricingHealth(props.models, props.catalog)))
|
||||
|
||||
const unpricedCount = computed<number>(() => rows.value.filter((r) => r.priceSource === 'unpriced').length)
|
||||
|
||||
/** SOURCE_BADGE_VARIANT mirrors lib/model-table-columns.ts's own priceCell badge treatment (WP-F, ModelCatalogTable.vue) so the two tables read consistently: destructive for unpriced (needs attention), secondary for a deliberate free model, outline for an explicit rule. */
|
||||
const SOURCE_BADGE_VARIANT: Record<PriceSource, 'destructive' | 'secondary' | 'outline'> = {
|
||||
unpriced: 'destructive',
|
||||
free: 'secondary',
|
||||
override: 'outline',
|
||||
builtin: 'outline',
|
||||
litellm: 'outline',
|
||||
}
|
||||
|
||||
function sourceCell(row: PricingHealthRow) {
|
||||
return h(Badge, { as: 'span', variant: SOURCE_BADGE_VARIANT[row.priceSource], class: 'font-normal' }, () => row.priceSource)
|
||||
}
|
||||
|
||||
function priceCell(row: PricingHealthRow) {
|
||||
if (row.inputPerMTokUsd === undefined || row.outputPerMTokUsd === undefined) return h('span', { class: 'text-muted-foreground' }, '—')
|
||||
return h('span', {}, formatModelCostHover(row.inputPerMTokUsd, row.outputPerMTokUsd))
|
||||
}
|
||||
|
||||
const columns: ColumnDef<PricingHealthRow, unknown>[] = [
|
||||
{
|
||||
id: 'id',
|
||||
header: 'Model',
|
||||
accessorFn: (r) => r.id,
|
||||
cell: ({ row }) => h(EntityLink, { label: row.original.id, kind: 'model', id: row.original.id }),
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
header: 'Source',
|
||||
accessorFn: (r) => r.priceSource,
|
||||
cell: ({ row }) => sourceCell(row.original),
|
||||
},
|
||||
{
|
||||
id: 'price',
|
||||
header: 'Price',
|
||||
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),
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* pricingHealthCsv exports exactly the rows on screen (unpriced-first
|
||||
* order, or whatever the reader has since re-sorted to — DataTable's own
|
||||
* `data` prop is passed through unmodified) as raw, unformatted values
|
||||
* (CsvExportButton.vue's own doc comment: "always reflects whatever rows
|
||||
* are CURRENTLY visible... never a stale snapshot") — costMicroUsd stays
|
||||
* micro-USD, not a pre-formatted "$X.XXXX" string, so the export re-opens
|
||||
* cleanly as a spreadsheet numeric column.
|
||||
*/
|
||||
function pricingHealthCsv(): string {
|
||||
return toCsv(
|
||||
['id', 'priceSource', 'displayFree', 'requests', 'tokensIn', 'tokensOut', 'costMicroUsd', 'contextTokens', 'inputPerMTokUsd', 'outputPerMTokUsd'],
|
||||
rows.value.map((r) => [
|
||||
r.id,
|
||||
r.priceSource,
|
||||
r.displayFree,
|
||||
r.requests,
|
||||
r.tokensIn,
|
||||
r.tokensOut,
|
||||
r.costMicroUsd,
|
||||
r.contextTokens ?? '',
|
||||
r.inputPerMTokUsd ?? '',
|
||||
r.outputPerMTokUsd ?? '',
|
||||
]),
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
Pricing health
|
||||
<Badge v-if="unpricedCount > 0" variant="destructive" class="font-normal">{{ unpricedCount }} unpriced</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>Every model served in range, joined with its billing price source.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<DataTable :columns="columns" :data="rows" empty-message="No model served any traffic in this range." />
|
||||
<div class="flex justify-end">
|
||||
<CsvExportButton filename="pricing-health.csv" :build="pricingHealthCsv" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
+110
-219
@@ -8,11 +8,12 @@ import {
|
||||
import { computed, h, reactive, watch } from 'vue'
|
||||
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import EntityLink from '@/components/EntityLink.vue'
|
||||
import ModelChip from '@/components/ModelChip.vue'
|
||||
import ProviderRateBadge from '@/components/ProviderRateBadge.vue'
|
||||
import ScopeLink from '@/components/ScopeLink.vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
@@ -26,79 +27,55 @@ import { formatAgo, formatContextWindow, formatLatencyMs, formatUntil, refreshDe
|
||||
import { isModelDegraded } from '@/lib/provider-rate'
|
||||
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useModelsStore } from '@/stores/models'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
import type { AdminAliasView, AdminLatencyView, AdminModelMetaView, AdminModelRateView, AdminProvenanceView, AdminProviderView } from '@/types/api'
|
||||
import type {
|
||||
AdminAliasView,
|
||||
AdminLatencyView,
|
||||
AdminModelMetaView,
|
||||
AdminModelRateView,
|
||||
AdminPerfRow,
|
||||
AdminProvenanceView,
|
||||
AdminProviderView,
|
||||
} from '@/types/api'
|
||||
|
||||
// This component backs the "Providers" tab (App.vue) — a UI-label rename
|
||||
// only. The data it renders still comes from GET /admin/api/overview
|
||||
// (admin.go's adminOverviewResponse) and the store field below keeps that
|
||||
// same "overview" name, since the backend route/type names are unchanged
|
||||
// API surface.
|
||||
/**
|
||||
* ProviderHealthPanel (redesign-plan.md section 3.4, Models & providers
|
||||
* page) is ProvidersView.vue's own Providers accordion + aliases table,
|
||||
* moved here verbatim, PLUS fleet-wide performance (p50/p95, timeouts,
|
||||
* failovers — GET /admin/api/performance?kind=provider, read from
|
||||
* useModelsStore, which the Models page's own onMounted fetches once for
|
||||
* the whole page, shared with ModelCatalogTable.vue). The per-replica
|
||||
* discovery/latency/provenance badges below are UNCHANGED from
|
||||
* ProvidersView.vue — still sourced from the SAME 5s dashboard poll
|
||||
* (GET /admin/api/overview), still per-replica/in-process (see each
|
||||
* badge's own doc comment for why that never averages across replicas).
|
||||
*/
|
||||
const dashboard = useDashboardStore()
|
||||
const models = useModelsStore()
|
||||
const overview = computed(() => dashboard.overview)
|
||||
const nav = useNavStore()
|
||||
|
||||
/**
|
||||
* goToProviderModels navigates to the Charts Models ranking, pre-filtered
|
||||
* to this provider's own models (F6, dashboard-plan.md: `nav.goToModels`).
|
||||
*
|
||||
* P2 review fix: this used to back a `<span role="link" tabindex="0">`
|
||||
* nested INSIDE AccordionTrigger's own real `<button>` — invalid per the
|
||||
* HTML button content model (no descendant may carry a `tabindex`
|
||||
* attribute, not just no nested button/anchor) and effectively invisible
|
||||
* to assistive technology (ARIA's `button` role declares "children
|
||||
* presentational: true", so the nested span was never announced as its
|
||||
* own link — see ScopeLink.vue's own doc comment for the identical
|
||||
* finding there). The provider-name label is now a real `<button>`,
|
||||
* rendered as a SIBLING of AccordionTrigger rather than nested inside it
|
||||
* (this component's template) — a plain click handler is enough: a click
|
||||
* on a sibling element never bubbles INTO the trigger's own listener
|
||||
* (event bubbling only travels through actual ancestors of the click
|
||||
* target), so the stopPropagation/Enter-only-keydown machinery this used
|
||||
* to need is gone along with the span it protected — native `<button>`
|
||||
* keyboard activation (Enter AND Space) is correct here for free.
|
||||
* goToProviderModels navigates to the Models catalog table, pre-filtered
|
||||
* (via its own `q` search) to this provider's own models — the redesign's
|
||||
* replacement for the pre-redesign nav.goToModels (stores/nav.ts no
|
||||
* longer has a per-page goTo* method at all, see that store's own doc
|
||||
* comment; every navigation goes through the single goTo(page, params)).
|
||||
*/
|
||||
function goToProviderModels(providerName: string): void {
|
||||
nav.goToModels(`${providerName}/`)
|
||||
nav.goTo('models', { q: `${providerName}/` })
|
||||
}
|
||||
|
||||
// --- provider expand/collapse (shadcn-vue Accordion) ---
|
||||
//
|
||||
// Providers render as a real shadcn-vue Accordion (operator directive:
|
||||
// use the library component, restructure the layout to fit it — the
|
||||
// earlier hand-rolled table-row-toggle accordion is retired). The
|
||||
// trigger is one provider's summary line; the content holds its detail
|
||||
// (base URL, last refresh, last error) plus its model-id chips.
|
||||
//
|
||||
// Expand STATE itself is unchanged: lib/search-expand.ts's plain,
|
||||
// Vue-free two-set module (manuallyExpanded/manuallyCollapsed, generalized
|
||||
// from provider-expand.ts so UsageView.vue's Groups accordion shares the
|
||||
// same tested logic) — the exact search-interaction bug fix a prior review
|
||||
// flagged, covered by a real vitest spec (lib/search-expand.spec.ts) that
|
||||
// exercises the module directly, without mounting Vue. Only the
|
||||
// template-facing adapter changed: expandedProviderValues below is a
|
||||
// writable computed translating that Set-based state into the string[]
|
||||
// shape Accordion's `type="multiple"` v-model expects, and back —
|
||||
// clicking a trigger fires the setter with the new array; diffing it
|
||||
// against the previous effective set finds the one name that changed
|
||||
// and replays it through the SAME tested toggleItemExpand used before, so
|
||||
// Accordion never owns this state itself, only displays it.
|
||||
// --- provider expand/collapse (shadcn-vue Accordion) — unchanged from ProvidersView.vue ---
|
||||
const expandState: ExpandState = reactive({
|
||||
manuallyExpanded: new Set<string>(),
|
||||
manuallyCollapsed: new Set<string>(),
|
||||
})
|
||||
|
||||
// --- model/alias search filter (operator feature) ---
|
||||
// --- model/alias search filter (operator feature) — unchanged from ProvidersView.vue ---
|
||||
const { query: modelQuery, normalized: normalizedQuery, hasQuery } = useSearchQuery()
|
||||
|
||||
/**
|
||||
* Matching is against each model's full ROUTABLE id
|
||||
* (routableModelId(p.name, m) — the same string ModelChip both displays
|
||||
* and copies), not the bare model id: copying a chip's text and pasting
|
||||
* it back into search must find it, and the routable form is strictly
|
||||
* more permissive (it contains the bare id as a substring), so this
|
||||
* never hides a match the bare-id form would have found.
|
||||
*/
|
||||
function modelMatches(providerName: string, modelId: string): boolean {
|
||||
return routableModelId(providerName, modelId).toLowerCase().includes(normalizedQuery.value)
|
||||
}
|
||||
@@ -118,7 +95,6 @@ const filteredAliases = computed<AdminAliasView[]>(() => {
|
||||
return hasQuery.value ? all.filter(aliasMatches) : all
|
||||
})
|
||||
|
||||
/** expandedProviders is the EFFECTIVE (possibly auto-expanded-by-search) set — see lib/search-expand.ts's own doc comments for the manual/auto-expand/override semantics. */
|
||||
const expandedProviders = computed<Set<string>>(() =>
|
||||
computeExpandedItems(
|
||||
expandState,
|
||||
@@ -127,18 +103,10 @@ const expandedProviders = computed<Set<string>>(() =>
|
||||
),
|
||||
)
|
||||
|
||||
/** toggleProvider reads the CURRENT effective (visible) state for name before flipping it — see toggleItemExpand's own doc comment for exactly which bug this avoids. */
|
||||
function toggleProvider(name: string): void {
|
||||
toggleItemExpand(expandState, name, expandedProviders.value.has(name))
|
||||
}
|
||||
|
||||
/**
|
||||
* expandedProviderValues adapts expandedProviders (a Set) to Accordion's
|
||||
* `type="multiple"` v-model contract (a string[]): reads out as
|
||||
* Array.from(expandedProviders.value); writes replay each name whose
|
||||
* membership changed through toggleProvider — Accordion never mutates
|
||||
* expandState directly, it only tells this setter what changed.
|
||||
*/
|
||||
const expandedProviderValues = computed<string[]>({
|
||||
get: () => Array.from(expandedProviders.value),
|
||||
set: (newValues) => {
|
||||
@@ -152,58 +120,33 @@ const expandedProviderValues = computed<string[]>({
|
||||
},
|
||||
})
|
||||
|
||||
// A stale manuallyCollapsed suppression from one search must never
|
||||
// silently carry into a later, unrelated one — see ExpandState's own doc
|
||||
// comment. Watching modelQuery (not just the explicit clear button)
|
||||
// covers backspacing to empty too.
|
||||
watch(modelQuery, (value) => {
|
||||
if (value.trim() === '') clearExpandOverrides(expandState)
|
||||
})
|
||||
|
||||
/** visibleModels is p's own model list, filtered to matches while a query is active — p only appears in filteredProviders at all because it has one, so this is never empty in that case; it exists so the accordion shows WHICH models matched instead of re-showing all 70+ with no distinction. */
|
||||
function visibleModels(p: AdminProviderView): string[] {
|
||||
return hasQuery.value ? p.models.filter((m) => modelMatches(p.name, m)) : p.models
|
||||
}
|
||||
|
||||
/** ZERO_MODEL_RATE is the fallback ProviderRateBadge reads for a model p.modelRates has no entry for — should not happen (admin.go's buildAdminOverview populates one entry per Models id, unconditionally), but a defensive fallback keeps a stale/mismatched client build from throwing rather than just under-reporting. */
|
||||
const ZERO_MODEL_RATE: AdminModelRateView = { attemptsDay: 0, failuresDay: 0 }
|
||||
|
||||
/**
|
||||
* modelRateFor looks up one model's counters within p.modelRates (Feature
|
||||
* A, v0.22), falling back to ZERO_MODEL_RATE. The optional-chain on
|
||||
* modelRates itself (folded review minor, v0.22 review round), not just
|
||||
* on the lookup, matches ZERO_MODEL_RATE's own doc comment: a stale
|
||||
* client talking to an older server build (before this field existed at
|
||||
* all) would otherwise throw on `.modelRates[model]` rather than fall
|
||||
* back — the type says modelRates is always present, but a real response
|
||||
* across a version skew is the one case that type cannot guarantee.
|
||||
*/
|
||||
function modelRateFor(p: AdminProviderView, model: string): AdminModelRateView {
|
||||
return p.modelRates?.[model] ?? ZERO_MODEL_RATE
|
||||
}
|
||||
|
||||
/** modelIsDegraded gates the per-model rate badge (spec: "ONLY when that model is degraded") so a healthy or no-traffic model's ModelChip renders with no badge beside it at all. */
|
||||
function modelIsDegraded(p: AdminProviderView, model: string): boolean {
|
||||
const r = modelRateFor(p, model)
|
||||
return isModelDegraded(r.attemptsDay, r.failuresDay)
|
||||
}
|
||||
|
||||
/** ZERO_MODEL_META is modelMetaFor's fallback, mirroring ZERO_MODEL_RATE's own doc comment above — every sub-field undefined reads as "nothing known", not a thrown error, across a version-skewed client/server pair. */
|
||||
const ZERO_MODEL_META: AdminModelMetaView = {}
|
||||
|
||||
/** modelMetaFor looks up one model's resolved metadata within p.modelMeta (feature v0.23), falling back to ZERO_MODEL_META. */
|
||||
function modelMetaFor(p: AdminProviderView, model: string): AdminModelMetaView {
|
||||
return p.modelMeta?.[model] ?? ZERO_MODEL_META
|
||||
}
|
||||
|
||||
// --- discovery circuit breaker (feat/provider-health) ---
|
||||
//
|
||||
// Badge shown ONLY for 'open'/'half-open' — mirroring modelIsDegraded's
|
||||
// own "no badge when healthy" convention above: a 'closed' provider (the
|
||||
// common case) renders no breaker badge at all, exactly like a
|
||||
// non-degraded model gets no ProviderRateBadge.
|
||||
// --- discovery circuit breaker (feat/provider-health) — unchanged from ProvidersView.vue ---
|
||||
|
||||
/** healthBadgeVariant maps healthState to the shadcn-vue Badge variant: destructive (backing off) for 'open', secondary (in progress) for 'half-open', undefined (no badge) for 'closed'. */
|
||||
function healthBadgeVariant(state: AdminProviderView['healthState']): 'destructive' | 'secondary' | undefined {
|
||||
switch (state) {
|
||||
case 'open':
|
||||
@@ -215,36 +158,17 @@ function healthBadgeVariant(state: AdminProviderView['healthState']): 'destructi
|
||||
}
|
||||
}
|
||||
|
||||
/** healthBadgeLabel renders the breaker badge's text: "open (in Ns)" while backing off (formatUntil omits the parenthetical once openUntil has passed or is unset), or "half-open" while probing. */
|
||||
function healthBadgeLabel(p: AdminProviderView): string {
|
||||
const until = formatUntil(p.openUntil)
|
||||
return until ? `${p.healthState} (${until})` : p.healthState
|
||||
}
|
||||
|
||||
/** healthDetailClass matches the accordion-content detail row's text color to healthBadgeVariant's own trigger-row Badge color, rather than hardcoding text-destructive for every non-closed state: destructive (red) for 'open', muted (grey, the same neutral tone secondary conveys on the Badge) for 'half-open'. */
|
||||
function healthDetailClass(state: AdminProviderView['healthState']): string {
|
||||
return state === 'open' ? 'text-destructive' : 'text-muted-foreground'
|
||||
}
|
||||
|
||||
// --- upstream latency (feat: instrument upstream latency) ---
|
||||
//
|
||||
// Beside the discovery-health badge above: no badge at all for a stream
|
||||
// state with no observations yet (p.latency undefined, or missing that
|
||||
// key) — mirrors healthBadgeVariant/modelIsDegraded's own "nothing to
|
||||
// show reads as no badge" convention, so absent data reads as absent,
|
||||
// never as a fabricated zero.
|
||||
//
|
||||
// The two stream states are deliberately labeled and worded differently,
|
||||
// never merged into one figure (task brief's own correctness rule):
|
||||
// streaming shows avgTtfbMs, the load-sensitive reading, labeled "ttfb";
|
||||
// non-streaming shows avgDurationMs labeled "avg" — NEVER "latency" or
|
||||
// "ttfb" — because a non-streaming provider buffers its whole completion
|
||||
// before sending anything, so its own avgTtfbMs is approximately equal
|
||||
// to avgDurationMs and carries the identical output-length contamination
|
||||
// a raw total-duration figure always has (a 4000-token answer legitimately
|
||||
// takes longer than a 50-token one on an equally healthy provider).
|
||||
// --- per-replica upstream latency (feat: instrument upstream latency) — unchanged from ProvidersView.vue ---
|
||||
|
||||
/** streamingLatency/nonStreamingLatency pull p.latency's two known stream-state keys (admin.go's buildAdminLatencyViews emits exactly these two strings) — undefined when that stream state has no observations yet. */
|
||||
function streamingLatency(p: AdminProviderView): AdminLatencyView | undefined {
|
||||
return p.latency?.streaming
|
||||
}
|
||||
@@ -252,57 +176,27 @@ function nonStreamingLatency(p: AdminProviderView): AdminLatencyView | undefined
|
||||
return p.latency?.['non-streaming']
|
||||
}
|
||||
|
||||
/** latencyObservationCount renders v.count (omitted, per admin.go, when 0) as a singular/plural detail phrase for a badge title, falling back to "no observations counted" for the defensive case of a present view with an absent count (version skew). */
|
||||
function latencyObservationCount(v: AdminLatencyView): string {
|
||||
if (v.count === undefined) return 'no observations counted'
|
||||
return `${v.count} ${v.count === 1 ? 'observation' : 'observations'} on this replica`
|
||||
}
|
||||
|
||||
/**
|
||||
* perReplicaCaveat is appended to every latency/provenance badge's title
|
||||
* (task brief's correctness rule 1): this figure comes from the same
|
||||
* in-process accumulator the rate-limit rejection counter reads, never
|
||||
* Redis, so it reflects only whichever of this deployment's several
|
||||
* Traefik replicas answered the current poll — never a fleet-wide
|
||||
* average, and a freshly restarted pod shows a short window. F4
|
||||
* (dashboard-plan.md) names the CONCRETE replica (admin.go's g.replica,
|
||||
* echoed as overview.replica) rather than the abstract warning alone — a
|
||||
* reader can now tell not just THAT this figure is replica-scoped, but
|
||||
* WHICH replica it came from. Falls back to the generic "this replica"
|
||||
* phrasing for a version-skewed server build that has not shipped the
|
||||
* field yet (undefined, never a fabricated id).
|
||||
*/
|
||||
function perReplicaCaveat(): string {
|
||||
const replica = overview.value?.replica
|
||||
const who = replica ? `replica ${replica}` : 'this replica'
|
||||
return `Per-replica (${who}), in-process only — not a fleet-wide average across this deployment’s replicas.`
|
||||
}
|
||||
|
||||
/** streamingLatencyTitle is the streaming ttfb badge's hover/title detail. */
|
||||
function streamingLatencyTitle(v: AdminLatencyView): string {
|
||||
return `Average time to first byte, the load-sensitive reading for streaming traffic. ${latencyObservationCount(v)}. ${perReplicaCaveat()}`
|
||||
}
|
||||
|
||||
/** nonStreamingLatencyTitle is the non-streaming avg-duration badge's hover/title detail — explicit that this is NOT a latency/ttfb reading (correctness rule 2). */
|
||||
function nonStreamingLatencyTitle(v: AdminLatencyView): string {
|
||||
return `Average total response time, including generation — not a load-sensitive signal like streaming ttfb, since this provider buffers the whole completion before sending anything. ${latencyObservationCount(v)}. ${perReplicaCaveat()}`
|
||||
}
|
||||
|
||||
// --- usage provenance (feat: expose token-accounting provenance) ---
|
||||
//
|
||||
// Token accounting has three provenances (see AdminProvenanceView's own
|
||||
// doc comment): reported (the healthy default — no badge, mirroring the
|
||||
// health/latency badges' own "nothing to show reads as no badge"
|
||||
// convention), estimated (a non-streaming response carried no usage, so
|
||||
// prompt was substituted from request body size), and unbilled (a
|
||||
// streaming response carried no usage, so the request was counted but
|
||||
// zero tokens were billed — a provider potentially serving completions
|
||||
// entirely free against every budget). estimated/unbilled are two
|
||||
// DISTINCT badges, never merged into one "not reported" indicator —
|
||||
// collapsing them would hide exactly the fact this feature exists to
|
||||
// surface (task brief's own correctness rule).
|
||||
// --- usage provenance (feat: expose token-accounting provenance) — unchanged from ProvidersView.vue ---
|
||||
|
||||
/** estimatedProvenance/unbilledProvenance pull p.provenance's two known kind keys (admin.go's buildAdminProvenanceViews emits only these two strings, never "reported") — undefined when that kind has no observations yet. */
|
||||
function estimatedProvenance(p: AdminProviderView): AdminProvenanceView | undefined {
|
||||
return p.provenance?.estimated
|
||||
}
|
||||
@@ -310,34 +204,48 @@ function unbilledProvenance(p: AdminProviderView): AdminProvenanceView | undefin
|
||||
return p.provenance?.unbilled
|
||||
}
|
||||
|
||||
/** estimatedProvenanceTitle is the estimated-accounting badge's hover/title detail. */
|
||||
function estimatedProvenanceTitle(v: AdminProvenanceView): string {
|
||||
const plural = v.requests === 1 ? '' : 's'
|
||||
return `${v.requests} non-streaming response${plural} carried no usage from the provider — prompt tokens were estimated from request body size instead (${v.tokens} tokens billed from the estimate), completion billed as zero. ${perReplicaCaveat()}`
|
||||
}
|
||||
|
||||
/** unbilledProvenanceTitle is the unbilled-accounting badge's hover/title detail — explicit about the budget-exposure implication (task brief's own framing). */
|
||||
function unbilledProvenanceTitle(v: AdminProvenanceView): string {
|
||||
const plural = v.requests === 1 ? '' : 's'
|
||||
return `${v.requests} streaming response${plural} carried no usage from the provider — the request was counted but zero tokens were billed. If this keeps happening, this provider may be serving completions entirely free against every budget. ${perReplicaCaveat()}`
|
||||
}
|
||||
|
||||
// --- model aliases (sortable DataTable, operator directive) ---
|
||||
// --- fleet-wide performance (NEW: GET /admin/api/performance?kind=provider) ---
|
||||
//
|
||||
// The alias id gets the ModelChip copy treatment (an alias name IS the
|
||||
// routable string a caller sends as `model`); the target column stays
|
||||
// plain text.
|
||||
// Fleet-wide (Redis-backed via the latency histogram counters, WP-A), UNLIKE
|
||||
// every badge above this point in the file — a provider's own p50/p95
|
||||
// here is the SAME reading regardless which replica answered the poll,
|
||||
// the opposite of the per-replica caveat every latency/provenance badge
|
||||
// above carries. timeouts/failovers are always populated (they do not
|
||||
// need admin.stats.latency — only the percentile fields do, AdminPerfRow's
|
||||
// own doc comment); a provider with zero of either renders no badge at
|
||||
// all, mirroring every other "nothing to show" convention in this file.
|
||||
|
||||
function providerPerfFor(name: string): AdminPerfRow | undefined {
|
||||
return models.providerPerf.find((r) => r.id === name)
|
||||
}
|
||||
|
||||
function fleetLatencyTitle(kind: 'p50' | 'p95'): string {
|
||||
const { window, span } = models.perfWindow
|
||||
return `Fleet-wide (every replica combined) ${kind === 'p50' ? 'median' : '95th percentile'} response time, over the last ${span} ${window}${span === 1 ? '' : 's'}.`
|
||||
}
|
||||
|
||||
function fleetCountTitle(kind: 'timeouts' | 'failovers'): string {
|
||||
const { window, span } = models.perfWindow
|
||||
const noun = kind === 'timeouts' ? 'requests that exceeded the deadline' : "requests failed away from this provider to another candidate"
|
||||
return `${noun}, fleet-wide, over the last ${span} ${window}${span === 1 ? '' : 's'}.`
|
||||
}
|
||||
|
||||
// --- model aliases (sortable DataTable) — unchanged from ProvidersView.vue ---
|
||||
const aliasColumns: ColumnDef<AdminAliasView, unknown>[] = [
|
||||
{
|
||||
id: 'alias',
|
||||
header: 'Alias',
|
||||
accessorFn: (a) => a.alias,
|
||||
// Optional-chained (review fix, folded minor) the same way
|
||||
// modelMetaFor guards the provider path below: AdminAliasView.
|
||||
// modelMeta is typed as always-present, but a real response from a
|
||||
// server build older than this field (version skew) would otherwise
|
||||
// throw on `.modelMeta.contextTokens` here instead of just omitting
|
||||
// the hover detail.
|
||||
cell: ({ row }) =>
|
||||
h(ModelChip, {
|
||||
id: row.original.alias,
|
||||
@@ -425,12 +333,17 @@ const aliasEmptyMessage = computed(() =>
|
||||
<CardHeader>
|
||||
<CardTitle>Providers</CardTitle>
|
||||
<CardDescription>
|
||||
Every configured upstream and its last discovery refresh. Search filters providers and aliases by
|
||||
model id.
|
||||
Every configured upstream, its last discovery refresh, and fleet-wide performance. Search filters providers and
|
||||
aliases by model id.
|
||||
</CardDescription>
|
||||
<SearchInput v-model="modelQuery" placeholder="Search models or aliases..." class="mt-2 max-w-sm" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<Alert v-if="!models.latencyEnabled" variant="warn">
|
||||
<AlertDescription>
|
||||
Fleet p50/p95 are off for this deployment. Enable <code>admin.stats.latency</code> to see them.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<p v-if="!overview?.providers.length" class="py-6 text-center text-sm text-muted-foreground">none</p>
|
||||
<p
|
||||
v-else-if="hasQuery && filteredProviders.length === 0"
|
||||
@@ -439,31 +352,15 @@ const aliasEmptyMessage = computed(() =>
|
||||
no providers match "{{ modelQuery }}"
|
||||
</p>
|
||||
<template v-else>
|
||||
<!-- F4 (dashboard-plan.md): latency/provenance badges below are per-replica, in-process figures — this caption names which replica this poll answered from, up front, instead of leaving it to each badge's own hover title. -->
|
||||
<p class="mb-2 text-xs text-muted-foreground">{{ perReplicaCaveat() }}</p>
|
||||
<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">
|
||||
<AccordionItem v-for="p in filteredProviders" :key="p.name" :value="p.name">
|
||||
<!--
|
||||
P2 review fix: the provider-name link moved OUTSIDE
|
||||
AccordionTrigger — see goToProviderModels' own doc comment
|
||||
above for why. `py-2.5` matches AccordionTrigger.vue's own
|
||||
vertical padding so the two line up in this row.
|
||||
`header-class="flex-1"` fills the AccordionHeader (<h3>) that
|
||||
wraps the trigger button, since AccordionTrigger's own
|
||||
`class` prop lands on the inner button, not the header — see
|
||||
AccordionTrigger.vue's doc comment. Without it the header
|
||||
shrinks to content width, so the chevron drifts left of the
|
||||
row's right edge and the empty space to its right stops
|
||||
being clickable. The sr-only span gives the toggle button an
|
||||
accessible name that names the provider, since the visible
|
||||
text beside it is just badges and counts.
|
||||
-->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 cursor-pointer rounded-sm py-2.5 font-medium hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
:aria-label="`View ${p.name}'s models in Charts`"
|
||||
:title="`View ${p.name}'s models in Charts`"
|
||||
:aria-label="`View ${p.name}'s models in the Models table`"
|
||||
:title="`View ${p.name}'s models in the Models table`"
|
||||
@click="goToProviderModels(p.name)"
|
||||
>{{ p.name }}</button>
|
||||
<AccordionTrigger header-class="flex-1">
|
||||
@@ -477,12 +374,6 @@ const aliasEmptyMessage = computed(() =>
|
||||
:attempts-minute="p.attemptsMinute"
|
||||
:failures-minute="p.failuresMinute"
|
||||
/>
|
||||
<!--
|
||||
Discovery circuit breaker (feat/provider-health): no
|
||||
badge at all while 'closed' (the healthy, common case),
|
||||
mirroring modelIsDegraded's own convention for the
|
||||
per-model rate badge above.
|
||||
-->
|
||||
<Badge
|
||||
v-if="p.healthState !== 'closed'"
|
||||
as="span"
|
||||
@@ -492,15 +383,6 @@ const aliasEmptyMessage = computed(() =>
|
||||
>
|
||||
{{ healthBadgeLabel(p) }}
|
||||
</Badge>
|
||||
<!--
|
||||
Upstream latency (feat: instrument upstream latency):
|
||||
streaming's ttfb is the load-sensitive figure; the
|
||||
non-streaming badge deliberately says "avg", never
|
||||
"ttfb"/"latency" — see nonStreamingLatencyTitle's own
|
||||
doc comment for why that number is not a load signal.
|
||||
No badge at all for a stream state with no observations
|
||||
yet, mirroring the health badge's own convention above.
|
||||
-->
|
||||
<Badge
|
||||
v-if="streamingLatency(p)?.avgTtfbMs !== undefined"
|
||||
as="span"
|
||||
@@ -519,15 +401,42 @@ const aliasEmptyMessage = computed(() =>
|
||||
>
|
||||
non-streaming avg {{ formatLatencyMs(nonStreamingLatency(p)!.avgDurationMs as number) }}
|
||||
</Badge>
|
||||
<!--
|
||||
Usage provenance (feat: expose token-accounting
|
||||
provenance): no badge at all for a kind with no
|
||||
observations yet, mirroring the health/latency badges'
|
||||
own convention above. estimated and unbilled are two
|
||||
distinct badges — never merged (correctness rule, task
|
||||
brief) — since one substitutes an approximation and the
|
||||
other bills nothing at all.
|
||||
-->
|
||||
<Badge
|
||||
v-if="providerPerfFor(p.name)?.p50Ms !== undefined"
|
||||
as="span"
|
||||
variant="outline"
|
||||
class="font-normal tabular-nums"
|
||||
:title="fleetLatencyTitle('p50')"
|
||||
>
|
||||
fleet p50 {{ formatLatencyMs(providerPerfFor(p.name)!.p50Ms as number) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="providerPerfFor(p.name)?.p95Ms !== undefined"
|
||||
as="span"
|
||||
variant="outline"
|
||||
class="font-normal tabular-nums"
|
||||
:title="fleetLatencyTitle('p95')"
|
||||
>
|
||||
fleet p95 {{ formatLatencyMs(providerPerfFor(p.name)!.p95Ms as number) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="(providerPerfFor(p.name)?.timeouts ?? 0) > 0"
|
||||
as="span"
|
||||
variant="secondary"
|
||||
class="font-normal tabular-nums"
|
||||
:title="fleetCountTitle('timeouts')"
|
||||
>
|
||||
{{ providerPerfFor(p.name)!.timeouts }} timeouts
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="(providerPerfFor(p.name)?.failovers ?? 0) > 0"
|
||||
as="span"
|
||||
variant="secondary"
|
||||
class="font-normal tabular-nums"
|
||||
:title="fleetCountTitle('failovers')"
|
||||
>
|
||||
{{ providerPerfFor(p.name)!.failovers }} failovers
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="estimatedProvenance(p)"
|
||||
as="span"
|
||||
@@ -583,17 +492,7 @@ const aliasEmptyMessage = computed(() =>
|
||||
:input-per-m-tok-usd="modelMetaFor(p, m).inputPerMTokUsd"
|
||||
:output-per-m-tok-usd="modelMetaFor(p, m).outputPerMTokUsd"
|
||||
/>
|
||||
<!-- ScopeLink (F6): jumps to Charts, scoped to this exact model — renders here as a real <button> inside AccordionContent (not AccordionTrigger), same as the provider-name button above (P2 review fix moved both out of the trigger). -->
|
||||
|
||||
<ScopeLink :label="routableModelId(p.name, m)" :scope="`model:${routableModelId(p.name, m)}`" />
|
||||
<!--
|
||||
Context chip, visible, muted (feature v0.23) — only
|
||||
when known. Cost stays hover-only on ModelChip itself
|
||||
(operator directive: chips + hover would otherwise
|
||||
show the identical price figures twice; context
|
||||
visible for scanning, price on hover for the exact
|
||||
numbers).
|
||||
-->
|
||||
<EntityLink :label="routableModelId(p.name, m)" kind="model" :id="routableModelId(p.name, m)" />
|
||||
<span
|
||||
v-if="modelMetaFor(p, m).contextTokens !== undefined"
|
||||
title="context window"
|
||||
@@ -601,14 +500,6 @@ const aliasEmptyMessage = computed(() =>
|
||||
>
|
||||
{{ formatContextWindow(modelMetaFor(p, m).contextTokens as number) }}
|
||||
</span>
|
||||
<!--
|
||||
attempts-minute/failures-minute deliberately omitted
|
||||
(SHOULD-2, v0.22 review round): AdminModelRateView no
|
||||
longer carries them at all, so leaving them unbound
|
||||
(undefined) is what tells ProviderRateBadge to render
|
||||
its day-window-only detail (dayRateTitle) instead of
|
||||
claiming a live minute-window reading no data backs.
|
||||
-->
|
||||
<ProviderRateBadge
|
||||
v-if="modelIsDegraded(p, m)"
|
||||
:attempts-day="modelRateFor(p, m).attemptsDay"
|
||||
@@ -5,10 +5,10 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { dayRateTitle, formatRatePercent, minuteRateTitle, providerRateStatus, providerSuccessRate } from '@/lib/provider-rate'
|
||||
|
||||
/**
|
||||
* ProviderRateBadge is the Providers tab's success-rate badge (Feature A,
|
||||
* v0.22) — reused for both a provider's own row (ProvidersView.vue's
|
||||
* ProviderRateBadge is the Models page's success-rate badge (Feature A,
|
||||
* v0.22) — reused for both a provider's own row (ProviderHealthPanel.vue's
|
||||
* accordion trigger, always shown) and a single degraded model beside its
|
||||
* ModelChip (ProvidersView.vue's accordion content, shown only when
|
||||
* ModelChip (ProviderHealthPanel.vue's accordion content, shown only when
|
||||
* isModelDegraded). attemptsMinute/failuresMinute are OPTIONAL (SHOULD-2,
|
||||
* v0.22 review round): the provider-row call site passes them (admin.go's
|
||||
* adminProviderView carries minute counters); the per-model call site
|
||||
@@ -56,11 +56,11 @@ const detail = computed(() =>
|
||||
|
||||
// Badge has no dedicated "amber"/"muted" variant (ui/badge/index.ts) —
|
||||
// severe reuses the built-in `destructive` variant exactly like every
|
||||
// other error state in this panel (e.g. ProvidersView's own lastErr
|
||||
// icon); healthy/no-traffic reuse `secondary`/`outline`, the panel's
|
||||
// existing "quiet" idioms (ProvidersView's provider-type badge is
|
||||
// `variant="secondary"`); degraded borrows the semantic --status-warn
|
||||
// token (main.css — MUST-1, v0.22 review round: --chart-tokens-out, this
|
||||
// other error state in this panel (e.g. ProviderHealthPanel.vue's own
|
||||
// lastErr icon); healthy/no-traffic reuse `secondary`/`outline`, the
|
||||
// panel's existing "quiet" idioms (ProviderHealthPanel.vue's
|
||||
// provider-type badge is `variant="secondary"`); degraded borrows the
|
||||
// semantic --status-warn token (main.css — MUST-1, v0.22 review round: --chart-tokens-out, this
|
||||
// component's original choice, measures ~2.22:1 against --card in light
|
||||
// mode, a WCAG 1.4.3 text-contrast failure on exactly the "something's
|
||||
// wrong" tier; --status-warn is tuned for body-text contrast instead,
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { faChartLine } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
|
||||
/**
|
||||
* ScopeLink (F6) is the one "jump to this scope's Charts view" control in
|
||||
* this panel: usage-columns.ts's id cell (Users/Groups tables and the
|
||||
* nested Members table), UsageView.vue's Groups accordion header, and
|
||||
* WP-B2's ProvidersView.vue per-model chips all render one of these
|
||||
* instead of each hand-rolling their own click-through-to-Charts wiring.
|
||||
*
|
||||
* A real `<button type="button">` (P2 review fix). This used to be a
|
||||
* `<span role="link" tabindex="0">`, justified at the time by the Groups
|
||||
* accordion trigger use site nesting it INSIDE shadcn-vue's
|
||||
* AccordionTrigger — itself a real `<button>` (reka-ui's own
|
||||
* AccordionTrigger primitive). That justification was wrong on its own
|
||||
* terms: the HTML button content model forbids ANY descendant carrying a
|
||||
* `tabindex` attribute, not just a nested button/anchor, and ARIA's
|
||||
* `button` role additionally declares "children presentational: true", so
|
||||
* assistive technology flattened the inner span and never exposed it as
|
||||
* its own link at all — axe flags this as `nested-interactive`. Reka's
|
||||
* AccordionTrigger also only listens for `click` (not `keydown`), so
|
||||
* `stopPropagation()` on click and Enter suppressed the accordion's own
|
||||
* toggle, but Space on the focused span did nothing and fell through to
|
||||
* page scroll instead of activating anything.
|
||||
*
|
||||
* The actual fix is structural, not a markup trick: every call site now
|
||||
* renders this component as a SIBLING of whatever it used to nest inside
|
||||
* (UsageView.vue's Groups header, ProvidersView.vue's provider-name
|
||||
* header — see both templates), never a descendant of another interactive
|
||||
* element, so a real `<button>` is valid there. No `role` override: a
|
||||
* plain button's implicit role is already correct here (activating it
|
||||
* performs an action — a programmatic Pinia-store navigation, not a
|
||||
* traditional `href` follow — which is exactly what a native `<button>`,
|
||||
* not `<a>`, is for; WAI-ARIA's first rule of ARIA use is not to override
|
||||
* a native element's semantics without a reason, and there is none left
|
||||
* here). `stopPropagation` on click stays — harmless everywhere (a table
|
||||
* cell, ProvidersView's per-model chips) and still correct defensively at
|
||||
* the Groups/Providers headers, where this button sits next to, not
|
||||
* inside, the accordion's own trigger.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** The visible + accessible label, e.g. a user id, group id, or model id. */
|
||||
label: string
|
||||
/** The scope string useNavStore.goToCharts expects — "total" | "user:{id}" | "group:{id}" | "model:{id}" (history.ts's own convention). */
|
||||
scope: string
|
||||
}>()
|
||||
|
||||
const nav = useNavStore()
|
||||
|
||||
function onClick(event: MouseEvent): void {
|
||||
event.stopPropagation()
|
||||
nav.goToCharts(props.scope)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="`View ${label} in Charts`"
|
||||
:title="`View ${label} in Charts`"
|
||||
class="inline-flex cursor-pointer items-center gap-1 rounded-sm text-inherit hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
@click="onClick"
|
||||
>
|
||||
<span>{{ label }}</span>
|
||||
<FontAwesomeIcon :icon="faChartLine" class="size-3" aria-hidden="true" />
|
||||
</button>
|
||||
</template>
|
||||
@@ -9,11 +9,13 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* SearchInput is the one search-box control every filter in this panel
|
||||
* uses — ProvidersView's model search, UsageView's user/group search, and
|
||||
* ChartsView's scope-picker search each grew an identical ~22-line
|
||||
* magnifying-glass-icon + Input + clear-button block; extracted into one
|
||||
* component (vue.md: "if you've written it twice, you owe an
|
||||
* abstraction"). A controlled component (modelValue/update:modelValue),
|
||||
* uses (today: ProviderHealthPanel's model search, ConsumerDirectory's
|
||||
* user/group search, TargetsView's target search, EventsView's user
|
||||
* filter, ModelCatalogTable's model search) — each pre-redesign call site
|
||||
* grew an identical ~22-line magnifying-glass-icon + Input + clear-button
|
||||
* block on its own; extracted into one component (vue.md: "if you've
|
||||
* written it twice, you owe an abstraction"). A controlled component
|
||||
* (modelValue/update:modelValue),
|
||||
* not v-model'd internally, since the incoming modelValue is a prop —
|
||||
* callers pair it with `useSearchQuery()` (composables/useSearchQuery.ts)
|
||||
* for the query/normalized/hasQuery/clear state, or v-model it against any
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { PAGE_NAV } from '@/lib/pages'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
|
||||
/**
|
||||
* SidebarNav (redesign-plan.md section 3.1) is the shell's primary
|
||||
* navigation: a vertical list of pages on a normal-width viewport,
|
||||
* collapsing to a horizontally-scrollable bar on a narrow one (the
|
||||
* `md:` breakpoint below) rather than a hamburger/drawer — every page is
|
||||
* still one tap away, and the panel is an internal admin tool with no
|
||||
* phone-first audience to justify hiding the list behind an extra tap.
|
||||
*
|
||||
* Real `<button>` elements, not `<a href>` (mirrors EntityLink.vue's own
|
||||
* structural note): activating one is a programmatic Pinia navigation
|
||||
* (nav.goTo), not a traditional link follow, and there is no separate
|
||||
* URL to navigate to server-side — the hash is derived FROM this state
|
||||
* (composables/useHashState.ts), not the other way around.
|
||||
*/
|
||||
const nav = useNavStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
class="flex gap-1 overflow-x-auto border-b border-border pb-2 md:w-52 md:shrink-0 md:flex-col md:border-r md:border-b-0 md:pr-3 md:pb-0"
|
||||
>
|
||||
<button
|
||||
v-for="entry in PAGE_NAV"
|
||||
:key="entry.id"
|
||||
type="button"
|
||||
:aria-current="nav.page === entry.id ? 'page' : undefined"
|
||||
class="flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 md:w-full"
|
||||
:class="nav.page === entry.id ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground'"
|
||||
@click="nav.goTo(entry.id)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="entry.icon" class="size-4" aria-hidden="true" />
|
||||
<span>{{ entry.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { faCheck, faCopy, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
|
||||
import { onUnmounted, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { copyText } from '@/lib/clipboard'
|
||||
|
||||
/**
|
||||
* SnippetBlock (redesign-plan.md section 3.4) is the one "here is text to
|
||||
* paste into a file" surface every ChangeHelper form and ConfigTree.vue
|
||||
* share — a `<pre>` plus a copy button (lib/clipboard.ts's copyText, same
|
||||
* Clipboard-API-with-selection-fallback convention CsvExportButton.vue's
|
||||
* download path and ModelChip.vue's own click-to-copy already use).
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** The exact text to display and copy — YAML (lib/snippets.ts) or a single-line JSON fragment, verbatim, never re-formatted by this component. */
|
||||
text: string
|
||||
/** A short caption above the block, e.g. "Paste into middleware.yaml" or "Add to the user's users.json line". */
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const preRef = useTemplateRef<HTMLElement>('pre')
|
||||
const state = ref<'idle' | 'copied' | 'selected' | 'failed'>('idle')
|
||||
let resetTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function onCopy(): Promise<void> {
|
||||
state.value = await copyText(props.text, preRef.value ?? undefined)
|
||||
clearTimeout(resetTimer)
|
||||
resetTimer = setTimeout(() => {
|
||||
state.value = 'idle'
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const BUTTON_LABEL: Record<typeof state.value, string> = {
|
||||
idle: 'Copy',
|
||||
copied: 'Copied',
|
||||
selected: 'Selected — press ⌘/Ctrl+C',
|
||||
failed: 'Copy failed',
|
||||
}
|
||||
|
||||
onUnmounted(() => clearTimeout(resetTimer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span v-if="label" class="text-xs font-medium text-muted-foreground">{{ label }}</span>
|
||||
<Button type="button" variant="outline" size="sm" class="ml-auto" @click="onCopy">
|
||||
<FontAwesomeIcon
|
||||
:icon="state === 'idle' ? faCopy : state === 'failed' ? faTriangleExclamation : faCheck"
|
||||
class="size-3.5"
|
||||
:class="state === 'failed' ? 'text-destructive' : undefined"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ BUTTON_LABEL[state] }}
|
||||
</Button>
|
||||
</div>
|
||||
<pre ref="pre" class="overflow-x-auto rounded-md border bg-muted p-3 font-mono text-xs select-text whitespace-pre">{{ text }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,8 +7,9 @@ import { computed } from 'vue'
|
||||
/**
|
||||
* SortHeaderButton is the one clickable, sortable-column control every
|
||||
* sortable header in the panel uses — DataTable.vue's real `<th>` header
|
||||
* buttons AND UsageView.vue's Groups sort toolbar (which has no `<table>`
|
||||
* to put a `<th>` in — see that view's own doc comment for why).
|
||||
* buttons AND ConsumerDirectory.vue's Groups sort toolbar (which has no
|
||||
* `<table>` to put a `<th>` in — see that component's own "Groups
|
||||
* accordion" doc comment for why).
|
||||
* Extracted so the button markup, sort-icon logic, click handler, and
|
||||
* accessible name exist in exactly one place (vue.md: "if you've written
|
||||
* it twice, you owe an abstraction"). Extra classes on this component
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import ClampedRangeNotice from '@/components/ClampedRangeNotice.vue'
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { adminFetch } from '@/lib/api'
|
||||
import { dayOrMonthWindow, totalsUrl } from '@/lib/range'
|
||||
import { targetCallerColumns } from '@/lib/target-columns'
|
||||
import { useFiltersStore } from '@/stores/filters'
|
||||
import type { AdminTotalsResponse } from '@/types/api'
|
||||
|
||||
/**
|
||||
* TargetCallers (redesign-plan.md section 3.4, MCP & Agents page) reads
|
||||
* GET /admin/api/usage/totals?kind=targetcaller — per-caller request
|
||||
* counts, ranked descending, dropped to zero rows server-side. Used in two
|
||||
* modes from TargetsPage.vue, sharing this one component (vue.md: "if
|
||||
* you've written it twice, you owe an abstraction") rather than a
|
||||
* near-duplicate for each:
|
||||
*
|
||||
* - Per-target ("TargetCallers.vue expander"): `target` is set to the
|
||||
* composed "mcp/{name}"/"agent/{name}" id (lib/target-columns.ts's
|
||||
* composeTargetId) of whichever row the reader clicked in TargetsView.vue
|
||||
* — shows only that target's own callers, no Target column (the panel's
|
||||
* own heading already names it).
|
||||
* - Fleet-wide ("top callers" card): `target` omitted — every configured
|
||||
* target's callers, ranked together, WITH a Target column so a caller
|
||||
* who reaches more than one target is not ambiguous.
|
||||
*
|
||||
* `kind=targetcaller` only supports day/month windows (redesign-plan.md
|
||||
* section 1.3.v) — lib/range.ts's dayOrMonthWindow clamps the global
|
||||
* filters store's current (possibly hour-window) range down to a window
|
||||
* this endpoint actually accepts, rather than firing an invalid request
|
||||
* whenever the reader has an hour-resolution range (24h/48h) selected.
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Composed "mcp/{name}" / "agent/{name}" id — omitted means fleet-wide, every target. */
|
||||
target?: string
|
||||
title: string
|
||||
limit?: number
|
||||
}>(),
|
||||
{ limit: 10 },
|
||||
)
|
||||
|
||||
const filters = useFiltersStore()
|
||||
|
||||
const rows = ref<AdminTotalsResponse['rows']>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
const error = ref('')
|
||||
/** reqId guards against an out-of-order response overwriting a newer one (latest-request-wins, stores/catalog.ts's own convention) — a quick target switch, or a range change mid-flight, no longer risks showing a STALE target's callers under the new heading. */
|
||||
let reqId = 0
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const requestId = ++reqId
|
||||
loading.value = true
|
||||
try {
|
||||
const { window, span } = dayOrMonthWindow(filters.window, filters.span)
|
||||
const res = await adminFetch<AdminTotalsResponse>(
|
||||
totalsUrl({ kind: 'targetcaller', window, span, metrics: ['req'], limit: props.limit, target: props.target }),
|
||||
)
|
||||
if (requestId !== reqId) return
|
||||
rows.value = res.rows
|
||||
error.value = ''
|
||||
} catch (err) {
|
||||
if (requestId !== reqId) return
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
if (requestId === reqId) {
|
||||
loading.value = false
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.target, () => filters.window, () => filters.span],
|
||||
() => void load(),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const columns = computed(() => targetCallerColumns(!props.target))
|
||||
|
||||
const emptyMessage = computed(() => {
|
||||
if (!loaded.value) return 'loading…'
|
||||
return 'no callers recorded in this window'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ title }}</CardTitle>
|
||||
<CardDescription>Ranked by requests, most recent day/month window available for the current range.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<ClampedRangeNotice :window="filters.window" :span="filters.span" />
|
||||
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
|
||||
<DataTable :columns="columns" :data="rows" :empty-message="emptyMessage" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -5,20 +5,38 @@ import DataTable from '@/components/DataTable.vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useSearchQuery } from '@/composables/useSearchQuery'
|
||||
import { targetColumns } from '@/lib/target-columns'
|
||||
import { composeTargetId, targetColumns } from '@/lib/target-columns'
|
||||
import type { TargetKind } from '@/lib/target-columns'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import type { AdminTargetView } from '@/types/api'
|
||||
|
||||
// This component backs the "MCP & Agents" tab (App.vue) — GET
|
||||
// /admin/api/targets (admin.go: adminTargetsResponse, Feature B v0.21).
|
||||
// This component backs the "MCP & Agents" page (redesign-plan.md section
|
||||
// 3.4, TargetsPage.vue) — GET /admin/api/targets (admin.go:
|
||||
// adminTargetsResponse, Feature B v0.21).
|
||||
const dashboard = useDashboardStore()
|
||||
const targets = computed(() => dashboard.targets)
|
||||
|
||||
/**
|
||||
* select (redesign-plan.md section 3.4's "TargetCallers.vue expander")
|
||||
* fires with the composed "mcp/{name}"/"agent/{name}" id and the target's
|
||||
* own display name — TargetsPage.vue listens and renders TargetCallers.vue
|
||||
* for whichever target the reader last clicked. Two separate
|
||||
* targetColumns() instantiations below (MCP servers, Agents) close over
|
||||
* their own fixed `kind` so a click always composes the correct prefix,
|
||||
* without this component needing to carry a per-row kind field itself.
|
||||
*/
|
||||
const emit = defineEmits<{ select: [id: string, label: string] }>()
|
||||
|
||||
function onSelect(kind: TargetKind): (t: AdminTargetView) => void {
|
||||
return (t) => emit('select', composeTargetId(kind, t.name), t.name)
|
||||
}
|
||||
|
||||
// Simple, single-field substring match on name — no separate lib/*-search
|
||||
// module: ProvidersView.vue's own aliasMatches/providerMatches stay inline
|
||||
// for the identical reason (one field, one comparison), reserving the
|
||||
// extracted-and-tested lib/usage-search.ts treatment for UsageView.vue's
|
||||
// genuinely multi-entity (user-or-group-member) matching semantics.
|
||||
// module: ProviderHealthPanel.vue's own aliasMatches/providerMatches stay
|
||||
// inline for the identical reason (one field, one comparison), reserving
|
||||
// the extracted-and-tested lib/usage-search.ts treatment for
|
||||
// ConsumerDirectory.vue's genuinely multi-entity (user-or-group-member)
|
||||
// matching semantics.
|
||||
const { query, normalized: normalizedQuery, hasQuery } = useSearchQuery()
|
||||
|
||||
function nameMatches(t: AdminTargetView): boolean {
|
||||
@@ -34,7 +52,7 @@ const filteredAgents = computed<AdminTargetView[]>(() => {
|
||||
return hasQuery.value ? all.filter(nameMatches) : all
|
||||
})
|
||||
|
||||
/** emptyMessage mirrors UsageView.vue's groupsEmptyMessage/usersEmptyMessage three-way pattern: distinguishes nothing configured from nothing matching the active query. */
|
||||
/** emptyMessage mirrors ConsumerDirectory.vue's groupsEmptyMessage/usersEmptyMessage three-way pattern: distinguishes nothing configured from nothing matching the active query. */
|
||||
function emptyMessage(configuredCount: number, filteredCount: number): string {
|
||||
if (!configuredCount) return 'none configured'
|
||||
if (hasQuery.value && filteredCount === 0) return `no targets match "${query.value}"`
|
||||
@@ -44,10 +62,13 @@ function emptyMessage(configuredCount: number, filteredCount: number): string {
|
||||
const mcpEmptyMessage = computed(() => emptyMessage(targets.value?.mcpServers.length ?? 0, filteredMCPServers.value.length))
|
||||
const agentsEmptyMessage = computed(() => emptyMessage(targets.value?.agents.length ?? 0, filteredAgents.value.length))
|
||||
|
||||
// One shared column-def set (lib/target-columns.ts) for both tables below —
|
||||
// stateless ColumnDef objects, safe to reuse across DataTable's two
|
||||
// separate useVueTable instances (each owns its own sorting state).
|
||||
const columns = targetColumns()
|
||||
// Two separate column-def sets (lib/target-columns.ts) — MCP servers and
|
||||
// Agents each close over their own fixed `kind` (see onSelect above) so a
|
||||
// name click composes the right "mcp/…"/"agent/…" id. Stateless ColumnDef
|
||||
// objects otherwise, safe to reuse across DataTable's two separate
|
||||
// useVueTable instances (each owns its own sorting state).
|
||||
const mcpColumns = targetColumns(onSelect('mcp'))
|
||||
const agentColumns = targetColumns(onSelect('agent'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -57,20 +78,26 @@ const columns = targetColumns()
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>MCP servers</CardTitle>
|
||||
<CardDescription>Configured MCP proxy targets, the groups allowed to reach them, and their request counters.</CardDescription>
|
||||
<CardDescription>
|
||||
Configured MCP proxy targets, the groups allowed to reach them, and their request counters. Click a name to see its
|
||||
callers.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable :columns="columns" :data="filteredMCPServers" :empty-message="mcpEmptyMessage" />
|
||||
<DataTable :columns="mcpColumns" :data="filteredMCPServers" :empty-message="mcpEmptyMessage" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agents</CardTitle>
|
||||
<CardDescription>Configured A2A agent proxy targets, the groups allowed to reach them, and their request counters.</CardDescription>
|
||||
<CardDescription>
|
||||
Configured A2A agent proxy targets, the groups allowed to reach them, and their request counters. Click a name to
|
||||
see its callers.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable :columns="columns" :data="filteredAgents" :empty-message="agentsEmptyMessage" />
|
||||
<DataTable :columns="agentColumns" :data="filteredAgents" :empty-message="agentsEmptyMessage" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import { computed, useId } from 'vue'
|
||||
import { Line } from 'vue-chartjs'
|
||||
|
||||
import '@/lib/chart-setup'
|
||||
import { useThemeColors } from '@/composables/useThemeColors'
|
||||
import { formatExactInt } from '@/lib/format'
|
||||
|
||||
/** One line-chart series: a label, its values (index-aligned with the `labels` prop below), and which main.css --chart-* token colors it. */
|
||||
export interface TimeSeriesDataset {
|
||||
label: string
|
||||
data: number[]
|
||||
/** A main.css --chart-* custom property name (e.g. '--chart-requests') — resolved live via getComputedStyle (this component's own resolveToken, below), so a chart already on screen repaints on a `prefers-color-scheme` flip. */
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* TimeSeriesComparisonDataset is one `comparisonDatasets` entry — a plain
|
||||
* TimeSeriesDataset plus an OPT-IN `labelSuffix`, appended to `label`
|
||||
* verbatim (undefined/empty appends nothing). Callers decide per dataset,
|
||||
* not this component for all of them: SpendPage.vue's own "vs previous
|
||||
* period" comparison sets `labelSuffix: ' (previous period)'` (Q5), while
|
||||
* BurnDownChart.vue's flat budget reference line — also rendered through
|
||||
* this same `comparisonDatasets` prop, since a budget ceiling is exactly
|
||||
* the "reference overlay, not a component of the current total" this prop
|
||||
* already means — sets none, so its line reads plain "Budget", never the
|
||||
* misleading "Budget (previous period)" (P2 item 5: a fixed
|
||||
* budget line is not a previous-period ANYTHING).
|
||||
*/
|
||||
export interface TimeSeriesComparisonDataset extends TimeSeriesDataset {
|
||||
labelSuffix?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* TimeSeriesChart (redesign-plan.md section 3.4) is the redesign's shared
|
||||
* line-chart primitive — Spend's cost breakdown (stacked), Reliability's
|
||||
* per-provider error rate (overlaid, not stacked), and any other
|
||||
* multi-series-over-time view build on this ONE component rather than
|
||||
* each growing its own vue-chartjs wiring. It is the only chart component
|
||||
* in this panel — lib/chart-setup.ts registers exactly the Line/Filler
|
||||
* pieces it uses, nothing more.
|
||||
*
|
||||
* `comparisonDatasets` (Q5/section 3.1 "vs previous period", and
|
||||
* BurnDownChart.vue's budget reference line) renders as a SEPARATE set of
|
||||
* dashed lines, one per entry, matched to its solid counterpart by array
|
||||
* index and color — never stacked, never filled, regardless of `stacked`,
|
||||
* since a comparison line is a reference overlay, not a component of the
|
||||
* current total.
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
labels: string[]
|
||||
datasets: TimeSeriesDataset[]
|
||||
/** A reference/overlay series, one dashed line per entry — omitted/empty draws no comparison. Each entry's own `labelSuffix` (TimeSeriesComparisonDataset) decides whether its label reads "vs previous period" or plain. */
|
||||
comparisonDatasets?: TimeSeriesComparisonDataset[]
|
||||
/** Stack every dataset into one filled total (Spend's cost breakdown) instead of overlaying them as independent lines (Reliability's per-provider error rate). */
|
||||
stacked?: boolean
|
||||
/** Formats a raw value for the tooltip and y-axis ticks — defaults to formatExactInt (lib/format.ts). Cost callers pass formatCost, a ratio series a percent formatter, and so on. */
|
||||
valueFormatter?: (value: number) => string
|
||||
/**
|
||||
* ariaLabel is this chart's accessible name (P3 item 16) — a bare
|
||||
* `<canvas>` has no text alternative at all for a screen reader, so
|
||||
* every call site supplies a short description of what the chart
|
||||
* shows (e.g. "Spend by model over time"). Required, not optional:
|
||||
* there is no safe generic default that would be meaningful across
|
||||
* this component's very different callers (Spend's breakdown,
|
||||
* BurnDownChart's burn-down, Reliability's five series charts,
|
||||
* UserDetail's two per-user charts).
|
||||
*/
|
||||
ariaLabel: string
|
||||
}>(),
|
||||
{
|
||||
comparisonDatasets: () => [],
|
||||
stacked: false,
|
||||
valueFormatter: formatExactInt,
|
||||
},
|
||||
)
|
||||
|
||||
const descId = `timeseries-chart-desc-${useId()}`
|
||||
|
||||
/**
|
||||
* srSummary is the sr-only fallback's own text content (P3 item 16's fix
|
||||
* instruction: "an sr-only summary (latest value, total)") — one short
|
||||
* sentence per dataset, latest bucket's value and the sum across the
|
||||
* whole range, run through the SAME valueFormatter the visible tooltip/
|
||||
* axis use, so a screen reader hears figures in the identical units a
|
||||
* sighted reader sees. Comparison datasets are left out: they are a
|
||||
* reference overlay of the SAME primary series shifted back a period,
|
||||
* not new information this summary needs to repeat.
|
||||
*/
|
||||
const srSummary = computed<string>(() => {
|
||||
if (props.datasets.length === 0 || props.labels.length === 0) return 'No data for this range.'
|
||||
return props.datasets
|
||||
.map((d) => {
|
||||
const latest = d.data.length > 0 ? d.data[d.data.length - 1] : 0
|
||||
const total = d.data.reduce((sum, v) => sum + v, 0)
|
||||
return `${d.label}: latest ${props.valueFormatter(latest)}, total ${props.valueFormatter(total)} over ${props.labels.length} buckets.`
|
||||
})
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
const { foreground, mutedForeground, border } = useThemeColors()
|
||||
|
||||
/** resolveToken reads one of main.css's --chart-* custom properties from :root, live — see this component's own doc comment on `color` above. */
|
||||
function resolveToken(cssVar: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
|
||||
}
|
||||
|
||||
const chartData = computed<ChartData<'line'>>(() => {
|
||||
// Touch the theme ref so this computed re-runs (and re-reads the CSS
|
||||
// custom properties via resolveToken) on a `prefers-color-scheme` flip.
|
||||
void foreground.value
|
||||
const solid = props.datasets.map((d) => {
|
||||
const color = resolveToken(d.color)
|
||||
return {
|
||||
label: d.label,
|
||||
data: d.data,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
fill: props.stacked,
|
||||
stack: props.stacked ? 'main' : undefined,
|
||||
tension: 0.2,
|
||||
pointRadius: 2,
|
||||
borderWidth: 2,
|
||||
}
|
||||
})
|
||||
const comparison = props.comparisonDatasets.map((d) => {
|
||||
const color = resolveToken(d.color)
|
||||
return {
|
||||
label: `${d.label}${d.labelSuffix ?? ''}`,
|
||||
data: d.data,
|
||||
borderColor: color,
|
||||
backgroundColor: 'transparent',
|
||||
borderDash: [6, 4],
|
||||
fill: false,
|
||||
tension: 0.2,
|
||||
pointRadius: 0,
|
||||
borderWidth: 1.5,
|
||||
}
|
||||
})
|
||||
return { labels: props.labels, datasets: [...solid, ...comparison] }
|
||||
})
|
||||
|
||||
const chartOptions = computed<ChartOptions<'line'>>(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
x: {
|
||||
stacked: props.stacked,
|
||||
grid: { color: border.value, display: false },
|
||||
ticks: { color: mutedForeground.value },
|
||||
},
|
||||
y: {
|
||||
// Zero-based, honest axis (dataviz rule) — never a truncated y-scale that would exaggerate a small change.
|
||||
stacked: props.stacked,
|
||||
beginAtZero: true,
|
||||
grid: { color: border.value },
|
||||
ticks: { color: mutedForeground.value, callback: (value) => props.valueFormatter(Number(value)) },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: props.datasets.length > 1 || props.comparisonDatasets.length > 0,
|
||||
labels: { color: foreground.value },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => `${ctx.dataset.label}: ${props.valueFormatter(ctx.parsed.y ?? 0)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-72 w-full" role="img" :aria-label="ariaLabel" :aria-describedby="descId">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
<p :id="descId" class="sr-only">{{ srSummary }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import EntityLink from '@/components/EntityLink.vue'
|
||||
|
||||
/** One TopList.vue row — an entity id/label, its formatted value, and which EntityLink.vue target it opens. */
|
||||
export interface TopListItem {
|
||||
/** The raw entity id EntityLink.vue navigates on (a user id or a canonical model id). */
|
||||
id: string
|
||||
/** The visible label — usually the same as `id`, but callers may pass a friendlier form. */
|
||||
label: string
|
||||
/** Already-formatted display value (lib/format.ts) — this component renders it verbatim, never reformats. */
|
||||
value: string
|
||||
kind: 'user' | 'model'
|
||||
}
|
||||
|
||||
/**
|
||||
* TopList (redesign-plan.md section 3.4) is the Home page's ranked-list
|
||||
* card: "top 5 users by cost", "top 5 models". Each row is an EntityLink
|
||||
* so clicking a user opens Consumers pre-filtered to them, a model opens
|
||||
* Models — the SAME "jump to detail" affordance every table's id column
|
||||
* (usage-columns.ts, model-table-columns.ts) offers, reused here rather
|
||||
* than a plain unlinked list.
|
||||
*/
|
||||
defineProps<{
|
||||
title: string
|
||||
items: TopListItem[]
|
||||
/** Shown in place of the list when `items` is empty — e.g. "no traffic in this range yet". */
|
||||
emptyText: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ title }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p v-if="items.length === 0" class="text-sm text-muted-foreground">{{ emptyText }}</p>
|
||||
<ol v-else class="flex flex-col gap-1.5">
|
||||
<li v-for="(item, i) in items" :key="item.id" class="flex items-center justify-between gap-3 text-sm">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span class="w-4 shrink-0 text-right text-xs text-muted-foreground">{{ i + 1 }}</span>
|
||||
<EntityLink :label="item.label" :kind="item.kind" :id="item.id" class="min-w-0 truncate" />
|
||||
</span>
|
||||
<span class="shrink-0 font-medium tabular-nums">{{ item.value }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -15,12 +15,13 @@ import { ratioTier } from '@/lib/usage-bars'
|
||||
* `aria-valuetext`, so a screen reader announces the same number a
|
||||
* sighted reader sees in the fill width and the `title` tooltip.
|
||||
*
|
||||
* Used two places (usage-columns.ts's numeric cells, UsageView.vue's
|
||||
* group detail grid) — both pass a fully-computed BudgetRatio plus the
|
||||
* caller's own human-readable `valueText`, so this component has no
|
||||
* formatting opinion of its own (formatCost/formatExactInt stay the
|
||||
* caller's job, same "logic lives in lib/, not the component" split
|
||||
* every other shared component in this panel follows).
|
||||
* Used across the panel (usage-columns.ts's numeric cells,
|
||||
* ConsumerDirectory.vue's group detail grid, UserDetail.vue's headroom
|
||||
* bars) — every caller passes a fully-computed BudgetRatio plus its own
|
||||
* human-readable `valueText`, so this component has no formatting
|
||||
* opinion of its own (formatCost/formatExactInt stay the caller's job,
|
||||
* same "logic lives in lib/, not the component" split every other
|
||||
* shared component in this panel follows).
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** used / limit — may exceed 1 (a scope's usage window can roll past its limit before the next request is rejected). */
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import { computed } from 'vue'
|
||||
import { Bar } from 'vue-chartjs'
|
||||
|
||||
import '@/lib/chart-setup'
|
||||
import { useThemeColors } from '@/composables/useThemeColors'
|
||||
import { formatBucketLabel, formatCompactCount, formatCost, formatExactInt } from '@/lib/format'
|
||||
import type { TimeSeriesTab } from '@/stores/history'
|
||||
import type { HistoryMetric, HistoryWindow, UsageHistoryPoint } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
tab: TimeSeriesTab
|
||||
window: HistoryWindow
|
||||
seriesByMetric: Partial<Record<HistoryMetric, UsageHistoryPoint[]>>
|
||||
}>()
|
||||
|
||||
const { foreground, mutedForeground, border } = useThemeColors()
|
||||
|
||||
/** One dataset spec per tab: which metric(s), its label, and its token color. */
|
||||
const DATASET_SPECS: Record<TimeSeriesTab, { metric: HistoryMetric; label: string; color: string }[]> = {
|
||||
requests: [{ metric: 'req', label: 'Requests', color: '--chart-requests' }],
|
||||
tokens: [
|
||||
{ metric: 'tokin', label: 'Tokens in', color: '--chart-tokens-in' },
|
||||
{ metric: 'tokout', label: 'Tokens out', color: '--chart-tokens-out' },
|
||||
],
|
||||
cost: [{ metric: 'cost', label: 'Cost (USD)', color: '--chart-cost' }],
|
||||
}
|
||||
|
||||
/** cost is stored as micro-USD (limits.go's account/history convention) — every other metric is already the display unit. */
|
||||
function toDisplayValue(metric: HistoryMetric, raw: number): number {
|
||||
return metric === 'cost' ? raw / 1_000_000 : raw
|
||||
}
|
||||
|
||||
/**
|
||||
* canonicalBuckets is the union of every active dataset's bucket keys,
|
||||
* sorted. The "tokens" tab fetches tokin and tokout as two separate HTTP
|
||||
* requests (history.ts's metricsForTab) — not one atomic call — so a
|
||||
* window rollover landing between the two requests can leave one series
|
||||
* one bucket ahead of the other. Zipping the two arrays positionally
|
||||
* would silently mis-stack that refresh's chart. Bucket strings are
|
||||
* fixed-width, zero-padded digit strings (limits.go's windowKey: hour
|
||||
* "2006010215", day "20060102", month "200601"), so a plain lexicographic
|
||||
* sort is also a chronological sort — no date parsing needed to align
|
||||
* them.
|
||||
*/
|
||||
const canonicalBuckets = computed<string[]>(() => {
|
||||
const specs = DATASET_SPECS[props.tab]
|
||||
const bucketSet = new Set<string>()
|
||||
for (const spec of specs) {
|
||||
for (const p of props.seriesByMetric[spec.metric] ?? []) bucketSet.add(p.bucket)
|
||||
}
|
||||
return Array.from(bucketSet).sort()
|
||||
})
|
||||
|
||||
const labels = computed<string[]>(() => canonicalBuckets.value.map((b) => formatBucketLabel(b, props.window)))
|
||||
|
||||
/** resolveToken reads one of main.css's --chart-* custom properties from :root, live — so a chart already on screen repaints on a `prefers-color-scheme` flip exactly like useThemeColors does for axis/legend text. */
|
||||
function resolveToken(cssVar: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
|
||||
}
|
||||
|
||||
const chartData = computed<ChartData<'bar'>>(() => {
|
||||
// Touch the theme refs so this computed re-runs (and re-reads the CSS
|
||||
// custom properties via resolveToken) on a `prefers-color-scheme` flip —
|
||||
// resolveToken's own getComputedStyle read is not itself reactive.
|
||||
void foreground.value
|
||||
const buckets = canonicalBuckets.value
|
||||
return {
|
||||
labels: labels.value,
|
||||
datasets: DATASET_SPECS[props.tab].map((spec) => {
|
||||
// Map<bucket, value> per series, so each canonical bucket looks up
|
||||
// its own value rather than assuming index i means the same bucket
|
||||
// across every dataset (the mis-stacking canonicalBuckets exists to
|
||||
// avoid — see its own doc comment). A bucket this series has no
|
||||
// point for reads as 0, matching the zero-based axis: a genuinely
|
||||
// missing bucket (not yet reported by the store) is indistinguishable
|
||||
// from zero usage, which is the honest reading for a bar chart.
|
||||
const byBucket = new Map((props.seriesByMetric[spec.metric] ?? []).map((p) => [p.bucket, p.value]))
|
||||
return {
|
||||
label: spec.label,
|
||||
backgroundColor: resolveToken(spec.color),
|
||||
data: buckets.map((b) => toDisplayValue(spec.metric, byBucket.get(b) ?? 0)),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const stacked = computed<boolean>(() => DATASET_SPECS[props.tab].length > 1)
|
||||
|
||||
const chartOptions = computed<ChartOptions<'bar'>>(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: stacked.value,
|
||||
grid: { color: border.value, display: false },
|
||||
ticks: { color: mutedForeground.value },
|
||||
},
|
||||
y: {
|
||||
// Zero-based, honest axis (dataviz rule): a bar chart's y-axis never
|
||||
// starts anywhere but 0, so bar length always means what it looks
|
||||
// like it means.
|
||||
stacked: stacked.value,
|
||||
beginAtZero: true,
|
||||
grid: { color: border.value },
|
||||
ticks: {
|
||||
color: mutedForeground.value,
|
||||
// Axis ticks stay compact (feature v0.23 addendum) — no room for
|
||||
// the exact figure on an axis label; the tooltip below carries
|
||||
// it. Cost reuses formatCost (the same formatter cost/day and
|
||||
// cost/month already render through elsewhere in the app) rather
|
||||
// than interpolating the raw tick float directly — Chart.js hands
|
||||
// this callback whatever value it picked for gridline placement,
|
||||
// which for a non-terminating division (e.g. cost/3) prints as
|
||||
// "0.30000000000000004" without it. formatCost takes micro-USD, so
|
||||
// the already-converted display value (toDisplayValue above) is
|
||||
// scaled back up before the call — .toFixed(4) inside it then
|
||||
// resolves any such float noise the same way the tooltip below
|
||||
// already does by hand.
|
||||
callback: (value) => (props.tab === 'cost' ? formatCost(Number(value) * 1_000_000) : formatCompactCount(Number(value))),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: stacked.value,
|
||||
labels: { color: foreground.value },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
// Tooltip shows the compact form WITH the precise raw value
|
||||
// alongside (feature v0.23 addendum) — e.g. "Tokens in: 1.23M
|
||||
// (1,234,567)" — since a tooltip, unlike an axis tick, has room
|
||||
// for both and precision must never be lost. Cost stays its
|
||||
// existing $-with-4-decimals format, unchanged.
|
||||
label: (ctx) => {
|
||||
const value = ctx.parsed.y ?? 0
|
||||
if (props.tab === 'cost') return `${ctx.dataset.label}: $${value.toFixed(4)}`
|
||||
return `${ctx.dataset.label}: ${formatCompactCount(value)} (${formatExactInt(value)})`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-72 w-full">
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -24,13 +24,17 @@ const props = defineProps<{
|
||||
emptyMessage?: string
|
||||
/** P1: the shared reactive clock (composables/useNow.ts) — threaded into usageColumns() below so this table's own costMonthProj column recomputes as the clock ticks, instead of freezing at whatever instant this computed first ran. */
|
||||
now: Date
|
||||
/** P6: optional controlled sort state, forwarded to DataTable's own `v-model:sorting` (see that component's identical prop doc comment) — omitted everywhere except UsageView.vue's top-level Users table, whose export must reflect the table's current sort. */
|
||||
/** P6: optional controlled sort state, forwarded to DataTable's own `v-model:sorting` (see that component's identical prop doc comment) — omitted everywhere except ConsumerDirectory.vue's top-level Users table, whose export must reflect the table's current sort. */
|
||||
sorting?: SortingState
|
||||
/** Optional per-row source lookup ('inline'/'file'/undefined), forwarded to usage-columns.ts's usageColumns — see that module's sourceColumn doc comment. Omitted everywhere except ConsumerDirectory.vue's flat Users table. */
|
||||
sourceValue?: (entry: AdminUsageEntryView) => string | undefined
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:sorting': [value: SortingState] }>()
|
||||
|
||||
const columns = computed(() => usageColumns(props.idLabel, props.secondaryColumnLabel, props.secondaryValue, props.now))
|
||||
const columns = computed(() =>
|
||||
usageColumns(props.idLabel, props.secondaryColumnLabel, props.secondaryValue, props.now, props.sourceValue),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import ClampedRangeNotice from '@/components/ClampedRangeNotice.vue'
|
||||
import CompactNumber from '@/components/CompactNumber.vue'
|
||||
import ModelChip from '@/components/ModelChip.vue'
|
||||
import TimeSeriesChart from '@/components/TimeSeriesChart.vue'
|
||||
import { Alert, AlertDescription } 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 { BUDGET_RATIO_LABEL, budgetRatios } from '@/lib/usage-bars'
|
||||
import { useConsumersStore } from '@/stores/consumers'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useEventsStore } from '@/stores/events'
|
||||
import { useFiltersStore } from '@/stores/filters'
|
||||
|
||||
/**
|
||||
* UserDetail (redesign-plan.md section 3.4) is the Consumers page's
|
||||
* `user=<name>` drill-down: a timeline of the user's own req/cost series,
|
||||
* their per-model breakdown (usermodel totals, when admin.stats.userModel
|
||||
* is on), recent errors filtered from the shared Events store, headroom
|
||||
* bars + an estimated run-out date, and their limits/grants.
|
||||
*/
|
||||
const props = defineProps<{ userId: string }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const dashboard = useDashboardStore()
|
||||
const consumers = useConsumersStore()
|
||||
const events = useEventsStore()
|
||||
const filters = useFiltersStore()
|
||||
const { now, start: startClock, stop: stopClock } = useNow()
|
||||
onMounted(startClock)
|
||||
onUnmounted(stopClock)
|
||||
|
||||
/** entry is this user's own row from the polled /admin/api/usage response — live counters, limits, lastSeen. undefined for a configured user who has never made a request (no usage scope created yet) or an id that does not exist at all. */
|
||||
const entry = computed(() => dashboard.usage?.users.find((u) => u.id === props.userId))
|
||||
|
||||
/** consumerUser is this user's own row from GET /admin/api/consumers — source, admin flag, personal grant. */
|
||||
const consumerUser = computed(() => consumers.data?.users.find((u) => u.name === props.userId))
|
||||
|
||||
const featuresUserModelStats = computed(() => dashboard.overview?.features.userModelStats ?? false)
|
||||
|
||||
function loadDetail(): void {
|
||||
void consumers.fetchUserDetail(props.userId, filters.window, filters.span, featuresUserModelStats.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDetail()
|
||||
void events.refresh()
|
||||
})
|
||||
// featuresUserModelStats is included alongside userId/range: on a deep
|
||||
// link or reload (#consumers?user=x), this component mounts and fires
|
||||
// loadDetail() BEFORE the polled overview (stores/dashboard.ts) has
|
||||
// necessarily landed its first response, so the very first loadDetail()
|
||||
// call can run with userModelStatsEnabled still false even though the
|
||||
// feature really is on. Once the overview lands and that computed flips
|
||||
// true, this watch refetches — otherwise "Models used" stayed
|
||||
// permanently empty for the rest of the session even after the alert
|
||||
// telling the reader to enable it disappeared (P2).
|
||||
watch([() => props.userId, () => filters.range, featuresUserModelStats], loadDetail)
|
||||
|
||||
const detail = computed(() => consumers.detail[props.userId])
|
||||
|
||||
// Labels are built from the RESPONSE's own `window` (AdminSeriesResponse),
|
||||
// not from the current filters.window directly — two fetchUserDetail
|
||||
// calls for different ranges can land out of order, and reading the
|
||||
// window off the live global filter would then mislabel a still-
|
||||
// displayed stale response's buckets (P1 item 7). Falls back to
|
||||
// filters.window only before any response has ever landed.
|
||||
const reqLabels = computed(() =>
|
||||
(detail.value?.reqSeries?.buckets ?? []).map((b) => formatBucketLabel(b, detail.value?.reqSeries?.window ?? filters.window)),
|
||||
)
|
||||
const reqDatasets = computed(() => [
|
||||
{ label: 'Requests', data: detail.value?.reqSeries?.series[0]?.points ?? [], color: '--chart-requests' },
|
||||
])
|
||||
const costLabels = computed(() =>
|
||||
(detail.value?.costSeries?.buckets ?? []).map((b) => formatBucketLabel(b, detail.value?.costSeries?.window ?? filters.window)),
|
||||
)
|
||||
const costDatasets = computed(() => [
|
||||
{ label: 'Cost', data: (detail.value?.costSeries?.series[0]?.points ?? []).map((v) => v), color: '--chart-cost' },
|
||||
])
|
||||
|
||||
/** showUserModelHint mirrors AttributionDrilldown.vue's own convention (Spend page): the hint shows when the client's own feature flag reads off, OR the usermodel call itself came back 404 (stores/consumers.ts's modelTotalsUnavailable) — the second case catches a stale/not-yet-loaded overview on a deep link or reload, where featuresUserModelStats can read true for a moment while the server still says otherwise. */
|
||||
const showUserModelHint = computed(() => !featuresUserModelStats.value || detail.value?.modelTotalsUnavailable === true)
|
||||
|
||||
/** modelRows ranks usermodel totals by cost desc (falling back to req when cost is absent from the requested metrics) — a small, page-local sort since this table has no other consumer that would justify a shared column/sort helper. */
|
||||
const modelRows = computed(() => {
|
||||
const rows = detail.value?.modelTotals?.rows ?? []
|
||||
return [...rows].sort((a, b) => (b.values.cost ?? b.values.req ?? 0) - (a.values.cost ?? a.values.req ?? 0))
|
||||
})
|
||||
|
||||
/** recentErrors filters the shared Events store (WP-F, read-only import) down to this user — the newest 10, matching the store's own newest-first order. */
|
||||
const recentErrors = computed(() => events.events.filter((e) => e.user === props.userId).slice(0, 10))
|
||||
|
||||
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
|
||||
* than a second, disagreeing implementation (P2 item 14: the two used to
|
||||
* differ on an already-exceeded budget and on day 1 of the month).
|
||||
*/
|
||||
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)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" @click="emit('close')">
|
||||
<FontAwesomeIcon :icon="faArrowLeft" class="size-3.5" aria-hidden="true" />
|
||||
Back to directory
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<CardTitle class="font-mono">{{ userId }}</CardTitle>
|
||||
<Badge v-if="consumerUser?.admin" variant="destructive">admin</Badge>
|
||||
<Badge v-if="consumerUser?.source" variant="secondary">{{ consumerUser.source }}</Badge>
|
||||
</div>
|
||||
<CardDescription v-if="entry?.groups?.length || entry?.groupName">
|
||||
Member of {{ (entry?.groups ?? [entry?.groupName]).filter(Boolean).join(', ') }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent v-if="!entry" class="text-sm text-muted-foreground">
|
||||
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>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Limits</p>
|
||||
<p class="text-sm">{{ formatLimits(entry.limits) }}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
</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)"
|
||||
/>
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Requests over time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p v-if="detail?.loading" class="text-sm text-muted-foreground">loading…</p>
|
||||
<p v-else-if="detail?.error" class="text-sm text-destructive">{{ detail.error }}</p>
|
||||
<TimeSeriesChart v-else :labels="reqLabels" :datasets="reqDatasets" :ariaLabel="`Requests over time for ${userId}`" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cost over time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p v-if="detail?.loading" class="text-sm text-muted-foreground">loading…</p>
|
||||
<p v-else-if="detail?.error" class="text-sm text-destructive">{{ detail.error }}</p>
|
||||
<TimeSeriesChart v-else :labels="costLabels" :datasets="costDatasets" :value-formatter="formatCost" :ariaLabel="`Cost over time for ${userId}`" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Models used</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3">
|
||||
<ClampedRangeNotice :window="filters.window" :span="filters.span" />
|
||||
<Alert v-if="showUserModelHint" variant="warn">
|
||||
<AlertDescription>Enable admin.stats.userModel in the plugin config to see a per-model breakdown for this user.</AlertDescription>
|
||||
</Alert>
|
||||
<Table v-else-if="modelRows.length">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead class="text-right">Requests</TableHead>
|
||||
<TableHead class="text-right">Cost</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="row in modelRows" :key="row.id">
|
||||
<TableCell><ModelChip :id="row.id" /></TableCell>
|
||||
<TableCell class="text-right tabular-nums"><CompactNumber :value="row.values.req ?? 0" /></TableCell>
|
||||
<TableCell class="text-right tabular-nums">{{ formatCost(row.values.cost ?? 0) }}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<p v-else class="py-6 text-center text-sm text-muted-foreground">no model usage in this range</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent errors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul v-if="recentErrors.length" class="flex flex-col gap-2 text-sm">
|
||||
<li v-for="(event, index) in recentErrors" :key="index" class="border-b pb-2 last:border-0 last:pb-0">
|
||||
<p class="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{{ event.kind }}</Badge>
|
||||
<span class="text-muted-foreground">{{ formatTimestamp(event.time) }}</span>
|
||||
</p>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-if="consumerUser?.providers?.length || consumerUser?.models?.length">
|
||||
<CardHeader>
|
||||
<CardTitle>Personal grant</CardTitle>
|
||||
<CardDescription>Access this user has beyond their group's own.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p class="mb-1.5 text-xs text-muted-foreground">Providers</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Badge v-for="name in consumerUser?.providers ?? []" :key="name" as="span" variant="secondary" class="font-mono font-normal">
|
||||
{{ name }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1.5 text-xs text-muted-foreground">Models</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<ModelChip v-for="id in consumerUser?.models ?? []" :key="id" :id="id" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from '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'
|
||||
|
||||
/**
|
||||
* GrantForm (ChangeHelper, redesign-plan.md section 3.4) builds a group's
|
||||
* access-list snippet (providers/models/mcpServers/agents, YAML) or a
|
||||
* user's personal grant (providers/models only — GroupConfig has no
|
||||
* per-user mcpServers/agents grant field, auth.go's own UserConfig shape)
|
||||
* as a bare JSON fragment for an existing users.json entry.
|
||||
*
|
||||
* Every list field is a comma-separated text Input rather than a proper
|
||||
* multi-select (no such shadcn-vue primitive is installed in this panel) —
|
||||
* parsed, trimmed, and de-duplicated below.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
groupNames: string[]
|
||||
userNames: string[]
|
||||
}>()
|
||||
|
||||
const targetKind = ref<'group' | 'user'>('group')
|
||||
const targetName = ref('')
|
||||
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
|
||||
// time (auth.go). Gated on targetKind so a group grant with an empty
|
||||
// providers list + a models list (a perfectly valid group config) no
|
||||
// longer shows a rejection that does not apply to it (P1 item 13).
|
||||
const grantError = computed(() => (targetKind.value === 'user' ? validatePersonalGrant(providers.value, models.value) : null))
|
||||
|
||||
const isEmpty = computed(
|
||||
() => providers.value.length === 0 && models.value.length === 0 && mcpServers.value.length === 0 && agents.value.length === 0,
|
||||
)
|
||||
|
||||
const snippet = computed<string | null>(() => {
|
||||
if (!nameValid.value || grantError.value || isEmpty.value) return null
|
||||
if (targetKind.value === 'group') {
|
||||
return groupGrantSnippet(targetName.value.trim(), {
|
||||
providers: providers.value,
|
||||
models: models.value,
|
||||
mcpServers: mcpServers.value,
|
||||
agents: agents.value,
|
||||
})
|
||||
}
|
||||
const fragment = userGrantJsonFragment({ providers: providers.value, models: models.value })
|
||||
return fragment || null
|
||||
})
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="grant-providers" class="text-xs font-medium text-muted-foreground">Providers (comma-separated, empty = all)</label>
|
||||
<Input id="grant-providers" v-model="providersRaw" placeholder="anthropic, openai" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="grant-models" class="text-xs font-medium text-muted-foreground">Models (comma-separated, empty = all)</label>
|
||||
<Input id="grant-models" v-model="modelsRaw" placeholder="anthropic/claude-sonnet-5" />
|
||||
</div>
|
||||
<template v-if="targetKind === 'group'">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="grant-mcp" class="text-xs font-medium text-muted-foreground">MCP servers (comma-separated, empty = all)</label>
|
||||
<Input id="grant-mcp" v-model="mcpServersRaw" placeholder="fetch, weather" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="grant-agents" class="text-xs font-medium text-muted-foreground">Agents (comma-separated, empty = all)</label>
|
||||
<Input id="grant-agents" v-model="agentsRaw" placeholder="agentkit" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Alert v-if="grantError" variant="destructive">
|
||||
<AlertDescription>{{ grantError }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SnippetBlock
|
||||
v-if="snippet"
|
||||
:label="targetKind === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName}’s users.json line`"
|
||||
: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>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from '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 type { LimitsConfig } from '@/types/api'
|
||||
|
||||
/**
|
||||
* LimitsForm (ChangeHelper, redesign-plan.md section 3.4) builds a
|
||||
* group's or a user's limits snippet — a group's is a full YAML block
|
||||
* (lib/snippets.ts's groupLimitsSnippet); a user's own limits live inside
|
||||
* their users.json line, so it is a bare JSON field fragment
|
||||
* (userLimitsJsonFragment) meant to be merged into that user's existing
|
||||
* entry, not a whole new line (UserForm.vue owns building a whole new
|
||||
* line).
|
||||
*/
|
||||
const props = defineProps<{
|
||||
groupNames: string[]
|
||||
userNames: string[]
|
||||
}>()
|
||||
|
||||
const targetKind = ref<'group' | 'user'>('group')
|
||||
const targetName = ref('')
|
||||
|
||||
/** 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'])
|
||||
|
||||
/** fieldValid applies isValidNonNegativeInt to the four int64 fields and isValidNonNegative (fractions allowed) to the two float64 cost fields — the ONE gate both invalidFields and limits below share, so they can never disagree on which fields are int-only. */
|
||||
function fieldValid(key: keyof LimitsConfig, value: number): boolean {
|
||||
return INTEGER_FIELDS.has(key) ? isValidNonNegativeInt(value) : isValidNonNegative(value)
|
||||
}
|
||||
|
||||
const FIELDS: { key: keyof LimitsConfig; label: string }[] = [
|
||||
{ key: 'requestsPerMinute', label: 'Requests/minute' },
|
||||
{ key: 'requestsPerDay', label: 'Requests/day' },
|
||||
{ key: 'tokensPerDay', label: 'Tokens/day (in+out)' },
|
||||
{ key: 'tokensPerMonth', label: 'Tokens/month (in+out)' },
|
||||
{ key: 'costPerDayUSD', label: 'Cost/day (USD)' },
|
||||
{ key: 'costPerMonthUSD', label: 'Cost/month (USD)' },
|
||||
]
|
||||
|
||||
const raw = reactive<Record<keyof LimitsConfig, string>>({
|
||||
requestsPerMinute: '',
|
||||
requestsPerDay: '',
|
||||
tokensPerDay: '',
|
||||
tokensPerMonth: '',
|
||||
costPerDayUSD: '',
|
||||
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),
|
||||
)
|
||||
const invalidIntegerFields = computed(() =>
|
||||
FIELDS.filter(({ key }) => INTEGER_FIELDS.has(key) && raw[key].trim() !== '' && !fieldValid(key, Number(raw[key]))).map((f) => f.label),
|
||||
)
|
||||
const invalidDecimalFields = computed(() =>
|
||||
FIELDS.filter(({ key }) => !INTEGER_FIELDS.has(key) && raw[key].trim() !== '' && !fieldValid(key, Number(raw[key]))).map((f) => f.label),
|
||||
)
|
||||
|
||||
const limits = computed<LimitsConfig>(() => {
|
||||
const out: LimitsConfig = {}
|
||||
for (const { key } of FIELDS) {
|
||||
const value = Number(raw[key])
|
||||
if (raw[key].trim() !== '' && fieldValid(key, value) && value > 0) out[key] = value
|
||||
}
|
||||
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
|
||||
return targetKind.value === 'group'
|
||||
? groupLimitsSnippet(targetName.value.trim(), limits.value)
|
||||
: userLimitsJsonFragment(limits.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<label :for="`limits-${field.key}`" class="text-xs font-medium text-muted-foreground">{{ field.label }}</label>
|
||||
<Input
|
||||
:id="`limits-${field.key}`"
|
||||
v-model="raw[field.key]"
|
||||
type="number"
|
||||
min="0"
|
||||
:step="INTEGER_FIELDS.has(field.key) ? '1' : 'any'"
|
||||
placeholder="unset"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert v-if="invalidFields.length" variant="destructive">
|
||||
<AlertDescription>
|
||||
<p v-if="invalidIntegerFields.length">Enter a non-negative whole number for: {{ invalidIntegerFields.join(', ') }}.</p>
|
||||
<p v-if="invalidDecimalFields.length">Enter a non-negative number for: {{ invalidDecimalFields.join(', ') }}.</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SnippetBlock
|
||||
v-if="snippet"
|
||||
:label="targetKind === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName}’s users.json line`"
|
||||
: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,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SnippetBlock from '@/components/SnippetBlock.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { isValidNonNegativeInt, modelMetaSnippet } from '@/lib/snippets'
|
||||
|
||||
/**
|
||||
* ModelMetaForm (ChangeHelper, redesign-plan.md section 3.4) builds a
|
||||
* model's contextTokens/free modelMeta entry — independent fields (unlike
|
||||
* PricingForm.vue's paired input/output price), either or both may be
|
||||
* set: contextTokens alone documents a window the upstream cannot report;
|
||||
* free alone marks a self-hosted model billed as 0 regardless of any
|
||||
* discovered/override price (pricing.go: modelMetaFree short-circuits
|
||||
* before override/builtin/LiteLLM resolution).
|
||||
*/
|
||||
const modelId = ref('')
|
||||
const contextRaw = ref('')
|
||||
const free = ref(false)
|
||||
|
||||
const modelIdValid = computed(() => modelId.value.trim() !== '')
|
||||
const contextValue = computed(() => Number(contextRaw.value))
|
||||
const contextEntered = computed(() => contextRaw.value.trim() !== '')
|
||||
// ModelMetaConfig.ContextTokens is Go `int` (llmgateway.go) — a fraction
|
||||
// like "128000.5" fails json.Unmarshal outright, so isValidNonNegativeInt
|
||||
// (not isValidNonNegative) is the right domain check here (P2 item 12).
|
||||
const contextValid = computed(() => !contextEntered.value || (isValidNonNegativeInt(contextValue.value) && contextValue.value > 0))
|
||||
|
||||
const snippet = computed<string | null>(() => {
|
||||
if (!modelIdValid.value || !contextValid.value) return null
|
||||
if (!contextEntered.value && !free.value) return null
|
||||
return modelMetaSnippet(modelId.value.trim(), {
|
||||
contextTokens: contextEntered.value ? contextValue.value : undefined,
|
||||
free: free.value || undefined,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="modelmeta-model-id" class="text-xs font-medium text-muted-foreground">Model id (provider/model)</label>
|
||||
<Input id="modelmeta-model-id" v-model="modelId" placeholder="gx10/current" class="font-mono" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="modelmeta-context" class="text-xs font-medium text-muted-foreground">Context window (tokens)</label>
|
||||
<Input id="modelmeta-context" v-model="contextRaw" type="number" min="1" step="1" placeholder="131072" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 pb-1.5 text-sm">
|
||||
<input id="modelmeta-free" v-model="free" type="checkbox" class="size-4 rounded border-input" />
|
||||
Free (billed as $0 regardless of any resolved price)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Alert v-if="contextEntered && !contextValid" variant="destructive">
|
||||
<AlertDescription>Context window must be a positive whole number of tokens.</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SnippetBlock v-if="snippet" label="Paste into middleware.yaml" :text="snippet" />
|
||||
<p v-else class="text-sm text-muted-foreground">Enter a model id and set a context window and/or free to build a snippet.</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SnippetBlock from '@/components/SnippetBlock.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { isValidNonNegative, pricingSnippet } from '@/lib/snippets'
|
||||
|
||||
/**
|
||||
* PricingForm (ChangeHelper, redesign-plan.md section 3.4) builds a
|
||||
* model's per-token pricing override — inputPerM/outputPerM, the two
|
||||
* ModelPricing fields (llmgateway.go) that live in the top-level
|
||||
* Config.Pricing map (`spec.plugin.llmgateway.pricing`) and OVERRIDE the
|
||||
* built-in/LiteLLM-derived price for one model id (pricing.go's
|
||||
* billingPriceSource: `override` always wins). This is a DIFFERENT config
|
||||
* section from ModelMetaForm.vue's own modelMeta entry — ModelMeta's own
|
||||
* cost fields drive metadata exposure only, never billing (see
|
||||
* lib/snippets.ts's own ModelMeta doc comment). Both fields are required
|
||||
* together — a one-sided price makes no billing sense — unlike
|
||||
* ModelMetaForm.vue's independent contextTokens/free fields.
|
||||
*/
|
||||
const modelId = ref('')
|
||||
const inputRaw = ref('')
|
||||
const outputRaw = ref('')
|
||||
|
||||
const modelIdValid = computed(() => modelId.value.trim() !== '')
|
||||
const inputValue = computed(() => Number(inputRaw.value))
|
||||
const outputValue = computed(() => Number(outputRaw.value))
|
||||
const inputValid = computed(() => inputRaw.value.trim() !== '' && isValidNonNegative(inputValue.value))
|
||||
const outputValid = computed(() => outputRaw.value.trim() !== '' && isValidNonNegative(outputValue.value))
|
||||
|
||||
const errors = computed(() => {
|
||||
const out: string[] = []
|
||||
if (inputRaw.value.trim() !== '' && !inputValid.value) out.push('Input price/MTok must be a non-negative number.')
|
||||
if (outputRaw.value.trim() !== '' && !outputValid.value) out.push('Output price/MTok must be a non-negative number.')
|
||||
if ((inputValid.value && !outputValid.value) || (!inputValid.value && outputValid.value)) {
|
||||
out.push('Set both input and output price/MTok — a one-sided override has no billing meaning.')
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const snippet = computed<string | null>(() => {
|
||||
if (!modelIdValid.value || !inputValid.value || !outputValid.value) return null
|
||||
return pricingSnippet(modelId.value.trim(), { inputPerM: inputValue.value, outputPerM: outputValue.value })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="pricing-model-id" class="text-xs font-medium text-muted-foreground">Model id (provider/model)</label>
|
||||
<Input id="pricing-model-id" v-model="modelId" placeholder="openai/gpt-4" class="font-mono" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="pricing-input" class="text-xs font-medium text-muted-foreground">Input price / MTok (USD)</label>
|
||||
<Input id="pricing-input" v-model="inputRaw" type="number" min="0" step="any" placeholder="0.50" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="pricing-output" class="text-xs font-medium text-muted-foreground">Output price / MTok (USD)</label>
|
||||
<Input id="pricing-output" v-model="outputRaw" type="number" min="0" step="any" placeholder="1.50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert v-if="errors.length" variant="destructive">
|
||||
<AlertDescription>
|
||||
<p v-for="(error, index) in errors" :key="index">{{ error }}</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SnippetBlock v-if="snippet" label="Paste into middleware.yaml" :text="snippet" />
|
||||
<p v-else class="text-sm text-muted-foreground">Enter a model id and both prices to build a snippet.</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from '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'
|
||||
|
||||
/**
|
||||
* UserForm (ChangeHelper, redesign-plan.md section 3.4) builds a whole new
|
||||
* users.json line — name, apiKey (placeholder by default, Q10 DECISION;
|
||||
* "Generate" produces a real client-side random key, never sent anywhere),
|
||||
* a primary group (production's own single-`group` convention) plus an
|
||||
* optional comma list of ADDITIONAL groups that switches the emitted field
|
||||
* to plural `groups` (lib/snippets.ts's userJsonLine), an optional
|
||||
* personal grant (validated the same way GrantForm.vue's user-target path
|
||||
* is), and an admin checkbox.
|
||||
*/
|
||||
const props = defineProps<{ groupNames: string[] }>()
|
||||
|
||||
const name = ref('')
|
||||
const apiKey = ref(API_KEY_PLACEHOLDER)
|
||||
const primaryGroup = ref('')
|
||||
const extraGroupsRaw = ref('')
|
||||
const providersRaw = ref('')
|
||||
const modelsRaw = ref('')
|
||||
const admin = ref(false)
|
||||
|
||||
function generate(): void {
|
||||
apiKey.value = generateApiKey()
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
const nameValid = computed(() => isValidUserName(name.value))
|
||||
const groupValid = computed(() => primaryGroup.value !== '')
|
||||
const apiKeyValid = computed(() => apiKey.value.trim() !== '' && apiKey.value !== API_KEY_PLACEHOLDER)
|
||||
const grantError = computed(() => validatePersonalGrant(providers.value, models.value))
|
||||
|
||||
const snippet = computed<string | null>(() => {
|
||||
if (!nameValid.value || !groupValid.value || !apiKeyValid.value || grantError.value) return null
|
||||
const allGroups = extraGroups.value.length > 0 ? [primaryGroup.value, ...extraGroups.value] : null
|
||||
return userJsonLine({
|
||||
name: name.value.trim(),
|
||||
apiKey: apiKey.value.trim(),
|
||||
group: allGroups ? undefined : primaryGroup.value,
|
||||
groups: allGroups ?? undefined,
|
||||
providers: providers.value,
|
||||
models: models.value,
|
||||
admin: admin.value,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="user-api-key" class="text-xs font-medium text-muted-foreground">API key</label>
|
||||
<div class="flex gap-2">
|
||||
<Input id="user-api-key" v-model="apiKey" class="font-mono" />
|
||||
<Button type="button" variant="outline" size="sm" @click="generate">Generate</Button>
|
||||
</div>
|
||||
<p v-if="apiKey === API_KEY_PLACEHOLDER" class="text-xs text-muted-foreground">Still the placeholder — click Generate for a real key.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="user-extra-groups" class="text-xs font-medium text-muted-foreground">Additional groups (comma-separated, optional)</label>
|
||||
<Input id="user-extra-groups" v-model="extraGroupsRaw" placeholder="friends" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="user-providers" class="text-xs font-medium text-muted-foreground">Personal grant: providers (optional)</label>
|
||||
<Input id="user-providers" v-model="providersRaw" placeholder="gx10" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="user-models" class="text-xs font-medium text-muted-foreground">Personal grant: models (optional)</label>
|
||||
<Input id="user-models" v-model="modelsRaw" placeholder="gx10/current" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="admin" type="checkbox" class="size-4 rounded border-input" />
|
||||
Admin (full access to /admin/api/*)
|
||||
</label>
|
||||
|
||||
<Alert v-if="grantError" variant="destructive">
|
||||
<AlertDescription>{{ grantError }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SnippetBlock v-if="snippet" label="Add as a new line in users.json" :text="snippet" />
|
||||
<p v-else class="text-sm text-muted-foreground">Fill in a name, group, and a real (generated) API key to build a snippet.</p>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user