feat(webui): shadcn-vue accordion + sortable DataTable, full-width panel

Component-first admin-panel rework (operator directive), replacing
hand-rolled patterns with the actual shadcn-vue library components
rather than working around them.

- Providers (OverviewView) and Groups (UsageView) now render as a real
  shadcn-vue Accordion: trigger is the summary line, content holds the
  detail (base URL, last refresh/error, model chips for providers;
  full stat grid + member users for groups) — the earlier hand-rolled
  second-<TableRow> toggle is retired. lib/provider-expand.ts's tested
  two-set expand-state module is unchanged; only the template now
  adapts it to Accordion's type="multiple" v-model via a writable
  computed instead of driving a plain v-if.
- Usage view: expanding a group shows its member users' own usage rows
  — a client-side join on AdminUsageEntryView.groupName, which admin.go
  already sets per user (verified via admin_test.go's existing
  TestAdminUsage_MathAgainstSeededCounters; no backend change needed).
- New DataTable.vue: the shadcn-vue DataTable pattern
  (@tanstack/vue-table, pinned to ^8.21.3 — the version whose API
  matches the documented pattern; npm's default "latest" resolves to
  v9, a breaking rewrite with a different API) factored into one
  reusable, generic component so every real table (users usage,
  aliases, a group's nested member list) gets clickable sortable
  headers with asc/desc indicators for free. usage-columns.ts holds
  the shared ColumnDef set; UsageTable.vue now delegates to DataTable.
  Groups themselves cannot be a literal <table> (Accordion wraps
  content in a <div>, illegal inside <tbody>), so their own
  sortability is driven by the same columns headlessly, surfaced as a
  small header-button toolbar above the Accordion.
- Aliases table: alias id gets the ModelChip copy treatment (the alias
  name is the routable string); target stays plain text.
- App.vue: dropped the max-w-6xl container — the panel now uses the
  full viewport width with responsive gutters (px-4 sm:px-6 lg:px-8).
  Table.vue already wraps every table in its own overflow-x-auto
  container, so a wide table still scrolls itself, never the page.

shadcn-vue add accordion re-added @lucide/vue and required swapping
its Chevron icons for FontAwesome (webui/README.md's own documented
trap); components.json's iconLibrary/font keys stayed absent
(verified main.css byte-identical after the add).

Browser-verified against the real integration stack (real /admin over
the Yaegi plugin, strict CSP incl. style-src 'self'): provider/group
accordion expand, search auto-expand through the new Accordion
adapter, sort toggles on aliases/users/groups-toolbar/nested-member
tables, alias copy-to-clipboard, full-width at 1920px. Zero console
errors (one unrelated MetaMask extension log).
This commit is contained in:
2026-08-21 11:12:59 +01:00
parent 92e9ad0c1e
commit 60d66ca147
16 changed files with 666 additions and 248 deletions
+11 -4
View File
@@ -71,11 +71,18 @@ npm run build # what `make admin-ui` runs, plus webui/generate.mjs
(Overview/Usage, 5s poll), `history` (Charts, fetch-on-selection + 30s
refresh).
- `src/components/` — `AuthGate`, `OverviewView`, `UsageView`,
`ChartsView`/`UsageChart`, `UsageTable`; `ui/` holds the shadcn-vue
primitives this panel actually uses (Card, Table, Tabs, Button, Input,
Alert, Select, Badge) — nothing installed and unused.
`ChartsView`/`UsageChart`, `UsageTable`, `DataTable` (the shared
sortable-table wrapper — shadcn-vue's DataTable pattern, `@tanstack/
vue-table`, generic over row type); `ui/` holds the shadcn-vue
primitives this panel actually uses (Accordion, Card, Table, Tabs,
Button, Input, Alert, Select, Badge) — nothing installed and unused.
Providers (Overview) and Groups (Usage) render as a real Accordion —
expanding a group shows its member users' own usage rows, a
client-side join on `AdminUsageEntryView.groupName`.
- `src/lib/` — `api.ts` (the `/admin/api/*` fetch wrapper), `format.ts`,
`key-storage.ts`, `chart-setup.ts`.
`key-storage.ts`, `chart-setup.ts`, `provider-expand.ts` (search-aware
expand state, adapted to drive Accordion's v-model), `usage-columns.ts`
(shared `ColumnDef`s for every usage table).
- `src/types/api.ts` — TypeScript mirrors of admin.go's JSON response
shapes (`adminOverviewResponse`, `adminUsageResponse`,
`usageHistoryResponse`).
+33
View File
@@ -11,6 +11,7 @@
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/vue-fontawesome": "^3.3.3",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.4.0",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
@@ -1753,6 +1754,19 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.8",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.8.tgz",
@@ -1763,6 +1777,25 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/vue-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.3.tgz",
"integrity": "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"vue": ">=3.2"
}
},
"node_modules/@tanstack/vue-virtual": {
"version": "3.13.36",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.36.tgz",
+1
View File
@@ -12,6 +12,7 @@
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/vue-fontawesome": "^3.3.3",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.4.0",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
+1 -1
View File
@@ -32,7 +32,7 @@ onMounted(() => {
</script>
<template>
<div class="mx-auto flex max-w-6xl flex-col gap-6 px-4 py-8">
<div class="flex w-full flex-col gap-6 px-4 py-8 sm:px-6 lg:px-8">
<header class="flex flex-col gap-1">
<h1 class="text-xl font-semibold tracking-tight">LLM Gateway &mdash; Admin</h1>
<p class="flex items-center gap-1.5 text-sm text-muted-foreground">
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts" generic="TData">
import type { ColumnDef, SortingState } from '@tanstack/vue-table'
import { faSort, faSortDown, faSortUp } from '@fortawesome/free-solid-svg-icons'
import { FlexRender, getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
import { ref } from 'vue'
import {
Table,
TableBody,
TableCell,
TableEmpty,
TableHead,
TableHeader,
TableRow,
valueUpdater,
} from '@/components/ui/table'
/**
* DataTable is the shared sortable-table shape every real tabular surface
* in the panel uses (users usage, a group's nested member-user rows,
* aliases) — the shadcn-vue DataTable pattern
* (shadcn-vue.com/docs/components/data-table: useVueTable +
* getSortedRowModel + FlexRender), factored into one reusable component
* instead of re-writing the same table setup and sortable-header-button
* boilerplate at every call site (vue.md: "if you've written it twice,
* you owe an abstraction"). Each column stays a plain TanStack
* `ColumnDef` — a string `header` label plus an optional custom `cell`
* renderer — and this component supplies the uniform
* clickable-header-with-asc/desc-icon chrome for every column that does
* not explicitly set `enableSorting: false`. `meta: { align: 'right' }`
* (declared in ui/table/utils.ts) right-aligns a numeric column's header
* button and cell content with tabular-nums, matching this panel's
* existing numeric-column convention.
*/
const props = defineProps<{
columns: ColumnDef<TData, unknown>[]
data: TData[]
/** Extra classes for one row, keyed by its own data (e.g. a destructive tint for a storeDown entry). */
rowClass?: (row: TData) => string | undefined
/** Shown in the empty-state row when data is empty. */
emptyMessage?: string
}>()
const sorting = ref<SortingState>([])
const table = useVueTable({
get data() {
return props.data
},
get columns() {
return props.columns
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: (updater) => valueUpdater(updater, sorting),
state: {
get sorting() {
return sorting.value
},
},
})
/** ariaSort maps a column's current sort direction to the `aria-sort` value native to a real <th> — set only on the sorted column, per spec. */
function ariaSort(dir: false | 'asc' | 'desc'): 'ascending' | 'descending' | undefined {
if (dir === 'asc') return 'ascending'
if (dir === 'desc') return 'descending'
return undefined
}
</script>
<template>
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
:class="header.column.columnDef.meta?.align === 'right' ? 'text-right' : undefined"
:aria-sort="header.column.getCanSort() ? (ariaSort(header.column.getIsSorted()) ?? 'none') : undefined"
>
<button
v-if="header.column.getCanSort()"
type="button"
class="-mx-1.5 inline-flex items-center gap-1 rounded px-1.5 py-0.5 hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring"
@click="header.column.toggleSorting(header.column.getIsSorted() === 'asc')"
>
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" />
<FontAwesomeIcon
:icon="header.column.getIsSorted() === 'asc' ? faSortUp : header.column.getIsSorted() === 'desc' ? faSortDown : faSort"
class="size-3 shrink-0"
:class="header.column.getIsSorted() ? 'text-foreground' : 'text-muted-foreground/60'"
aria-hidden="true"
/>
</button>
<FlexRender v-else-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableEmpty v-if="table.getRowModel().rows.length === 0" :colspan="columns.length" class="text-muted-foreground">
{{ emptyMessage ?? 'none' }}
</TableEmpty>
<TableRow v-for="row in table.getRowModel().rows" :key="row.id" :class="rowClass?.(row.original)">
<TableCell
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:class="cell.column.columnDef.meta?.align === 'right' ? 'text-right tabular-nums' : undefined"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
</TableRow>
</TableBody>
</Table>
</template>
+114 -164
View File
@@ -1,15 +1,18 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import {
faChevronRight,
faCircleCheck,
faCircleXmark,
faMagnifyingGlass,
faTriangleExclamation,
faXmark,
} from '@fortawesome/free-solid-svg-icons'
import { computed, reactive, ref, watch } from 'vue'
import { computed, h, reactive, ref, watch } from 'vue'
import DataTable from '@/components/DataTable.vue'
import ModelChip from '@/components/ModelChip.vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
@@ -18,15 +21,6 @@ import {
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
TableCell,
TableEmpty,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { formatAgo, formatTimestamp, routableModelId } from '@/lib/format'
import { type ExpandState, clearExpandOverrides, computeExpandedProviders, toggleProviderExpand } from '@/lib/provider-expand'
import { useDashboardStore } from '@/stores/dashboard'
@@ -35,42 +29,25 @@ import type { AdminAliasView, AdminProviderView } from '@/types/api'
const dashboard = useDashboardStore()
const overview = computed(() => dashboard.overview)
// --- provider row expand/collapse ---
// --- provider expand/collapse (shadcn-vue Accordion) ---
//
// Providers table rows expand to a model list (operator feature). This is
// NOT shadcn-vue's Accordion or Collapsible component: both wrap
// trigger+content in one <div>, which cannot legally sit between two
// <tr> elements inside a <table>/<tbody>. This app renders entirely via
// Vue's DOM APIs (createElement/appendChild), never by parsing an HTML
// string, so the HTML5 parser's "foster parenting" algorithm — which
// only runs while building a DOM tree FROM a token stream — never fires
// here at all; a <div> placed as a <tbody> child by direct DOM
// manipulation stays exactly where it was put. CSS2.1 §17.2.1 does
// define this case: a non-row child of a row-group box is wrapped in
// an anonymous table-row plus an anonymous table-cell, not left
// undefined. What actually breaks is column alignment: that one
// anonymous cell holds the div's entire contents, outside the table's
// real column structure, so none of it lines up with the table's
// actual columns. Either way, a <div> does not belong there.
// (reka-ui's Collapsible primitives DO support an `as` prop that could in
// principle render Root as a <tbody> and Trigger/Content as <tr> —
// installed and inspected via the CLI to check, then not used:
// CollapsibleContent's animation-measurement code calls
// getBoundingClientRect() and sets inline transition/animation styles on
// whatever element `as` names, an interaction with a <tr> this component
// was never designed around and was not worth taking on for an instant
// show/hide with no transition.) A second, plain <TableRow> toggled by
// v-if is the correct table-native shape for an expandable row; it
// reuses Accordion's own visual language (a rotating chevron, a
// keyboard-accessible trigger) without misusing a component built for
// block content.
// 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 (computeExpandedProviders/toggleProviderExpand/
// clearExpandOverrides) is a plain, Vue-free module — lib/provider-expand.ts
// — specifically so the exact toggle-during-search sequence a review
// flagged is checkable by a throwaway assertion script outside Vue,
// without standing up a component-test framework this project does not
// otherwise have.
// Expand STATE itself is unchanged: lib/provider-expand.ts's plain,
// Vue-free two-set module (manuallyExpanded/manuallyCollapsed) — the
// exact search-interaction bug fix a prior review flagged, checkable by
// its own throwaway assertion script 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 toggleProviderExpand used
// before, so Accordion never owns this state itself, only displays it.
const expandState: ExpandState = reactive({
manuallyExpanded: new Set<string>(),
manuallyCollapsed: new Set<string>(),
@@ -86,9 +63,8 @@ const hasQuery = computed(() => normalizedQuery.value.length > 0)
* (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, plus the
* provider prefix), so this never hides a match the bare-id form would
* have found.
* 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)
@@ -109,7 +85,7 @@ const filteredAliases = computed<AdminAliasView[]>(() => {
return hasQuery.value ? all.filter(aliasMatches) : all
})
/** expandedProviders is what the template reads to decide which rows show their model list — see lib/provider-expand.ts's own doc comments for the manual/auto-expand/override semantics. */
/** expandedProviders is the EFFECTIVE (possibly auto-expanded-by-search) set — see lib/provider-expand.ts's own doc comments for the manual/auto-expand/override semantics. */
const expandedProviders = computed<Set<string>>(() =>
computeExpandedProviders(
expandState,
@@ -123,6 +99,26 @@ function toggleProvider(name: string): void {
toggleProviderExpand(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) => {
const next = new Set(newValues)
for (const name of expandedProviders.value) {
if (!next.has(name)) toggleProvider(name)
}
for (const name of next) {
if (!expandedProviders.value.has(name)) toggleProvider(name)
}
},
})
// 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)
@@ -136,23 +132,32 @@ function visibleModels(p: AdminProviderView): string[] {
return hasQuery.value ? p.models.filter((m) => modelMatches(p.name, m)) : p.models
}
/**
* providerModelsId is the id shared by a provider's chevron button
* (aria-controls, only set while expanded — see the template) and its
* expanded content row (id). Provider names are not escaped before this
* template-literal interpolation: buildAdapters (providers.go) rejects
* any name that does not match configNamePattern
* (`^[a-zA-Z0-9._-]+$`, providers.go) before this app ever sees it, so
* no provider name this endpoint can return contains a character an
* HTML id/attribute value needs escaped.
*/
function providerModelsId(name: string): string {
return `provider-models-${name}`
}
function clearQuery(): void {
modelQuery.value = ''
}
// --- model aliases (sortable DataTable, operator directive) ---
//
// 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.
const aliasColumns: ColumnDef<AdminAliasView, unknown>[] = [
{
id: 'alias',
header: 'Alias',
accessorFn: (a) => a.alias,
cell: ({ row }) => h(ModelChip, { id: row.original.alias }),
},
{
id: 'target',
header: 'Target',
accessorFn: (a) => a.target,
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, row.original.target),
},
]
const aliasEmptyMessage = computed(() =>
!overview.value?.aliases.length ? 'none configured' : hasQuery.value ? `no aliases match "${modelQuery.value}"` : 'none',
)
</script>
<template>
@@ -247,83 +252,51 @@ function clearQuery(): void {
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Base URL</TableHead>
<TableHead class="text-right">Models</TableHead>
<TableHead>Last refresh</TableHead>
<TableHead>Last error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableEmpty v-if="!overview?.providers.length" :colspan="6" class="text-muted-foreground">
none
</TableEmpty>
<TableEmpty
v-else-if="hasQuery && filteredProviders.length === 0"
:colspan="6"
class="text-muted-foreground"
>
no providers match &quot;{{ modelQuery }}&quot;
</TableEmpty>
<template v-for="p in filteredProviders" :key="p.name">
<TableRow class="cursor-pointer select-none hover:bg-accent/50" @click="toggleProvider(p.name)">
<TableCell class="font-medium">
<!--
A real <button>, not role="button" on the <tr>: a <tr>
carries table-row AT semantics (a screen reader
announces it as part of the table's row/column
structure), and overriding that to "button" strips
those semantics from the whole row — exactly the kind
of ARIA-over-native mistake the "semantics first"
rule warns against. The button lives in the natural
host, the chevron+name span, and needs no click
handler of its own: its native click (mouse, or Enter/
Space while focused — free, standard <button>
behavior, no keydown handling to write) bubbles up to
the row's own @click above, so exactly one place
(the row) ever runs the actual toggle. aria-controls
is set ONLY while expanded (not unconditionally): the
content row is v-if, not v-show (Lazy rendering — see
the models div below), so an id it would point to
while collapsed does not exist in the DOM yet, and
ARIA requires aria-controls name an id that exists.
-->
<button
type="button"
class="-my-1 flex items-center gap-2 rounded py-1 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring"
:aria-expanded="expandedProviders.has(p.name)"
:aria-controls="expandedProviders.has(p.name) ? providerModelsId(p.name) : undefined"
>
<FontAwesomeIcon
:icon="faChevronRight"
class="size-3 shrink-0 text-muted-foreground transition-transform duration-150"
:class="expandedProviders.has(p.name) ? 'rotate-90' : ''"
aria-hidden="true"
/>
{{ p.name }}
</button>
</TableCell>
<TableCell class="text-muted-foreground">{{ p.type }}</TableCell>
<TableCell class="text-muted-foreground">{{ p.baseUrl }}</TableCell>
<TableCell class="text-right tabular-nums">{{ p.modelCount }}</TableCell>
<TableCell class="text-muted-foreground">{{ formatTimestamp(p.lastRefresh) }}</TableCell>
<TableCell class="text-destructive">{{ p.lastErr ?? '' }}</TableCell>
</TableRow>
<TableRow v-if="expandedProviders.has(p.name)" :id="providerModelsId(p.name)">
<TableCell colspan="6" class="whitespace-normal bg-muted/30">
<div v-if="visibleModels(p).length" class="flex flex-wrap gap-1.5 py-1">
<ModelChip v-for="m in visibleModels(p)" :key="m" :id="routableModelId(p.name, m)" />
</div>
<p v-else class="py-1 text-sm text-muted-foreground">no models known yet</p>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
<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"
class="py-6 text-center text-sm text-muted-foreground"
>
no providers match &quot;{{ modelQuery }}&quot;
</p>
<Accordion v-else v-model="expandedProviderValues" type="multiple" class="rounded-md border px-3">
<AccordionItem v-for="p in filteredProviders" :key="p.name" :value="p.name">
<AccordionTrigger>
<div class="flex flex-1 flex-wrap items-center gap-x-3 gap-y-1 pr-2 text-left">
<span class="font-medium">{{ p.name }}</span>
<Badge variant="secondary" class="font-normal">{{ p.type }}</Badge>
<span class="text-xs text-muted-foreground tabular-nums">{{ p.modelCount }} models</span>
<FontAwesomeIcon
v-if="p.lastErr"
:icon="faTriangleExclamation"
class="size-3.5 shrink-0 text-destructive"
aria-hidden="true"
/>
<span class="text-xs text-muted-foreground">refreshed {{ formatAgo(p.lastRefresh) || 'never' }}</span>
</div>
</AccordionTrigger>
<AccordionContent>
<dl class="mb-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm sm:grid-cols-3">
<div>
<dt class="text-xs text-muted-foreground">Base URL</dt>
<dd class="break-all">{{ p.baseUrl }}</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">Last refresh</dt>
<dd>{{ formatTimestamp(p.lastRefresh) }}</dd>
</div>
<div v-if="p.lastErr">
<dt class="text-xs text-muted-foreground">Last error</dt>
<dd class="text-destructive">{{ p.lastErr }}</dd>
</div>
</dl>
<div v-if="visibleModels(p).length" class="flex flex-wrap gap-1.5">
<ModelChip v-for="m in visibleModels(p)" :key="m" :id="routableModelId(p.name, m)" />
</div>
<p v-else class="text-sm text-muted-foreground">no models known yet</p>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
@@ -333,30 +306,7 @@ function clearQuery(): void {
<CardDescription>Operator-defined alias &rarr; target model mappings.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Alias</TableHead>
<TableHead>Target</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableEmpty v-if="!overview?.aliases.length" :colspan="2" class="text-muted-foreground">
none configured
</TableEmpty>
<TableEmpty
v-else-if="hasQuery && filteredAliases.length === 0"
:colspan="2"
class="text-muted-foreground"
>
no aliases match &quot;{{ modelQuery }}&quot;
</TableEmpty>
<TableRow v-for="a in filteredAliases" :key="a.alias">
<TableCell class="font-medium">{{ a.alias }}</TableCell>
<TableCell class="text-muted-foreground">{{ a.target }}</TableCell>
</TableRow>
</TableBody>
</Table>
<DataTable :columns="aliasColumns" :data="filteredAliases" :empty-message="aliasEmptyMessage" />
</CardContent>
</Card>
+19 -71
View File
@@ -1,85 +1,33 @@
<script setup lang="ts">
import { formatCost, formatLimits } from '@/lib/format'
import {
Table,
TableBody,
TableCell,
TableEmpty,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { computed } from 'vue'
import DataTable from '@/components/DataTable.vue'
import { usageColumns } from '@/lib/usage-columns'
import type { AdminUsageEntryView } from '@/types/api'
/**
* UsageTable renders one scope kind's usage rows (users or groups) — the
* shared table shape both need (vue.md: extract before you paste it
* twice). The second column differs per kind: a user's row shows the group
* it belongs to; a group's row shows its member count (looked up from the
* Overview response, which is the only place member counts live).
* UsageTable renders one scope kind's usage rows (users, or a group's
* member users) as a sortable DataTable (shadcn-vue's DataTable pattern,
* via the shared usageColumns() defs — vue.md: extract before you paste
* it twice). The second column differs per kind: a user's row shows the
* group it belongs to; a group's row shows its member count (looked up
* from the Overview response, which is the only place member counts
* live).
*/
defineProps<{
const props = defineProps<{
idLabel: string
entries: AdminUsageEntryView[]
secondaryColumnLabel: string
secondaryValue: (entry: AdminUsageEntryView) => string
}>()
const columns = computed(() => usageColumns(props.idLabel, props.secondaryColumnLabel, props.secondaryValue))
</script>
<template>
<Table>
<TableHeader>
<TableRow>
<TableHead>{{ idLabel }}</TableHead>
<TableHead>{{ secondaryColumnLabel }}</TableHead>
<TableHead>Limits</TableHead>
<TableHead class="text-right">req/min</TableHead>
<TableHead class="text-right">req/day</TableHead>
<TableHead class="text-right">tokIn/day</TableHead>
<TableHead class="text-right">tokOut/day</TableHead>
<TableHead class="text-right">tokIn/month</TableHead>
<TableHead class="text-right">tokOut/month</TableHead>
<TableHead class="text-right">cost/day</TableHead>
<TableHead class="text-right">cost/month</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableEmpty v-if="entries.length === 0" :colspan="11" class="text-muted-foreground">
none
</TableEmpty>
<TableRow
v-for="entry in entries"
:key="entry.id"
:class="entry.storeDown ? 'bg-destructive/10' : undefined"
>
<TableCell class="font-medium">{{ entry.id }}</TableCell>
<TableCell class="text-muted-foreground">{{ secondaryValue(entry) }}</TableCell>
<TableCell class="text-muted-foreground">{{ formatLimits(entry.limits) }}</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.requestsPerMinute }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.requestsPerDay }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.tokensInPerDay }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.tokensOutPerDay }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.tokensInPerMonth }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : entry.tokensOutPerMonth }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : formatCost(entry.costPerDayMicroUsd) }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ entry.storeDown ? '?' : formatCost(entry.costPerMonthMicroUsd) }}
</TableCell>
</TableRow>
</TableBody>
</Table>
<DataTable
:columns="columns"
:data="entries"
:row-class="(entry) => (entry.storeDown ? 'bg-destructive/10' : undefined)"
/>
</template>
+147 -8
View File
@@ -1,9 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { SortingState } from '@tanstack/vue-table'
import { faSort, faSortDown, faSortUp } from '@fortawesome/free-solid-svg-icons'
import { FlexRender, getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
import { computed, ref } from 'vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { valueUpdater } from '@/components/ui/table'
import UsageTable from '@/components/UsageTable.vue'
import { formatCost } from '@/lib/format'
import { formatCost, formatLimits } from '@/lib/format'
import { usageColumns } from '@/lib/usage-columns'
import { useDashboardStore } from '@/stores/dashboard'
import type { AdminUsageEntryView } from '@/types/api'
@@ -24,6 +30,66 @@ function memberCountOf(entry: AdminUsageEntryView): string {
const count = memberCounts.value[entry.id]
return count === undefined ? '' : String(count)
}
/**
* membersOf returns one group's own member users' usage rows — a user's
* row already carries its own group name (admin.go's
* adminUsageEntryView.GroupName, set in buildAdminUsage), so this is a
* plain client-side join against the SAME GET /admin/api/usage response
* already in the store, no extra fetch. Verified this field already
* exists and is already asserted by a backend test
* (admin_test.go's TestAdminUsage_MathAgainstSeededCounters checks
* alice.GroupName == "agroup") — no backend change was needed for this.
*/
function membersOf(group: AdminUsageEntryView): AdminUsageEntryView[] {
return (usage.value?.users ?? []).filter((u) => u.groupName === group.id)
}
// --- Groups accordion (operator directive) ---
//
// Groups render as a shadcn-vue Accordion, same component and rationale
// as Providers (OverviewView.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 exactly like DataTable.vue's
// own sortable header buttons (same icons, same toggleSorting call,
// same aria-sort), just laid out as a toolbar instead of <th>s, because
// there is no <table> here to put a <th> in.
const groupColumns = usageColumns('Name', 'Members', memberCountOf)
const groupSorting = ref<SortingState>([])
const groupsTable = useVueTable({
get data() {
return usage.value?.groups ?? []
},
get columns() {
return groupColumns
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: (updater) => valueUpdater(updater, groupSorting),
state: {
get sorting() {
return groupSorting.value
},
},
})
const sortedGroupRows = computed(() => groupsTable.getRowModel().rows)
/** ariaSort mirrors DataTable.vue's own helper — kept local since this toolbar isn't a <table> (aria-sort still applies to the toolbar buttons' own semantics, not a <th>, so it's set as a data attribute for styling/testing rather than the real ARIA property here). */
function isSorted(id: string): false | 'asc' | 'desc' {
return groupsTable.getColumn(id)?.getIsSorted() ?? false
}
/** Which groups are open — plain array state (no search-driven auto-expand exists for this view, unlike Providers, so the two-set provider-expand.ts module is not needed here). */
const expandedGroups = ref<string[]>([])
</script>
<template>
@@ -56,14 +122,87 @@ function memberCountOf(entry: AdminUsageEntryView): string {
<Card>
<CardHeader>
<CardTitle>Groups</CardTitle>
<CardDescription>Expand a group to see its member users' own usage.</CardDescription>
</CardHeader>
<CardContent>
<UsageTable
id-label="Name"
secondary-column-label="Members"
:entries="usage?.groups ?? []"
:secondary-value="memberCountOf"
/>
<div
v-if="sortedGroupRows.length"
class="mb-3 flex flex-wrap gap-x-4 gap-y-1 text-xs font-medium text-muted-foreground"
>
<button
v-for="header in groupsTable.getHeaderGroups()[0].headers"
:key="header.id"
type="button"
class="inline-flex items-center gap-1 rounded px-1 py-0.5 hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring"
:data-sort="isSorted(header.column.id) || 'none'"
@click="header.column.toggleSorting(isSorted(header.column.id) === 'asc')"
>
<FlexRender :render="header.column.columnDef.header" :props="header.getContext()" />
<FontAwesomeIcon
:icon="isSorted(header.column.id) === 'asc' ? faSortUp : isSorted(header.column.id) === 'desc' ? faSortDown : faSort"
class="size-3 shrink-0"
:class="isSorted(header.column.id) ? 'text-foreground' : 'text-muted-foreground/50'"
aria-hidden="true"
/>
</button>
</div>
<Accordion v-if="sortedGroupRows.length" v-model="expandedGroups" type="multiple" class="rounded-md border px-3">
<AccordionItem v-for="row in sortedGroupRows" :key="row.original.id" :value="row.original.id">
<AccordionTrigger>
<div class="flex flex-1 flex-wrap items-center gap-x-4 gap-y-1 pr-2 text-left">
<span class="font-medium">{{ row.original.id }}</span>
<span class="text-xs text-muted-foreground tabular-nums">{{ memberCountOf(row.original) || '0' }} members</span>
<span class="text-xs text-muted-foreground tabular-nums">
{{ row.original.storeDown ? '?' : row.original.requestsPerDay }} req/day
</span>
<span class="text-xs text-muted-foreground tabular-nums">
{{ row.original.storeDown ? '?' : formatCost(row.original.costPerDayMicroUsd) }}/day
</span>
</div>
</AccordionTrigger>
<AccordionContent>
<dl class="mb-3 grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm sm:grid-cols-4">
<div>
<dt class="text-xs text-muted-foreground">Limits</dt>
<dd>{{ formatLimits(row.original.limits) }}</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">req/min</dt>
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : row.original.requestsPerMinute }}</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">tokIn/day</dt>
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : row.original.tokensInPerDay }}</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">tokOut/day</dt>
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : row.original.tokensOutPerDay }}</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">tokIn/month</dt>
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : row.original.tokensInPerMonth }}</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">tokOut/month</dt>
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : row.original.tokensOutPerMonth }}</dd>
</div>
<div>
<dt class="text-xs text-muted-foreground">cost/month</dt>
<dd class="tabular-nums">{{ row.original.storeDown ? '?' : formatCost(row.original.costPerMonthMicroUsd) }}</dd>
</div>
</dl>
<p class="mb-1.5 text-xs font-medium text-muted-foreground">Members</p>
<UsageTable
id-label="Name"
secondary-column-label="Group"
:entries="membersOf(row.original)"
:secondary-value="groupNameOf"
/>
</AccordionContent>
</AccordionItem>
</Accordion>
<p v-else class="py-6 text-center text-sm text-muted-foreground">none</p>
</CardContent>
</Card>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { AccordionRootEmits, AccordionRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
AccordionRoot,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<AccordionRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AccordionRoot
v-slot="slotProps"
data-slot="accordion"
v-bind="forwarded"
:class="cn('flex w-full flex-col', props.class)"
>
<slot v-bind="slotProps" />
</AccordionRoot>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { AccordionContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AccordionContent } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AccordionContent
data-slot="accordion-content"
v-bind="delegatedProps"
class="data-open:animate-accordion-down data-closed:animate-accordion-up text-sm overflow-hidden"
>
<div
:class="cn(
'pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4',
props.class,
)"
>
<slot />
</div>
</AccordionContent>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { AccordionItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AccordionItem, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AccordionItem
v-slot="slotProps"
data-slot="accordion-item"
v-bind="forwardedProps"
:class="cn('not-last:border-b', props.class)"
>
<slot v-bind="slotProps" />
</AccordionItem>
</template>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { AccordionTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
import { reactiveOmit } from '@vueuse/core'
import {
AccordionHeader,
AccordionTrigger,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
data-slot="accordion-trigger"
v-bind="delegatedProps"
:class="
cn(
'focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground rounded-lg py-2.5 text-left text-sm font-medium hover:underline focus-visible:ring-3 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none disabled:pointer-events-none disabled:opacity-50',
props.class,
)
"
>
<slot />
<slot name="icon">
<FontAwesomeIcon
:icon="faChevronDown"
data-slot="accordion-trigger-icon"
aria-hidden="true"
class="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
/>
<FontAwesomeIcon
:icon="faChevronUp"
data-slot="accordion-trigger-icon"
aria-hidden="true"
class="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
/>
</slot>
</AccordionTrigger>
</AccordionHeader>
</template>
@@ -0,0 +1,4 @@
export { default as Accordion } from './Accordion.vue'
export { default as AccordionContent } from './AccordionContent.vue'
export { default as AccordionItem } from './AccordionItem.vue'
export { default as AccordionTrigger } from './AccordionTrigger.vue'
+1
View File
@@ -7,3 +7,4 @@ export { default as TableFooter } from './TableFooter.vue'
export { default as TableHead } from './TableHead.vue'
export { default as TableHeader } from './TableHeader.vue'
export { default as TableRow } from './TableRow.vue'
export { valueUpdater } from './utils'
+20
View File
@@ -0,0 +1,20 @@
import type { RowData, Updater } from '@tanstack/vue-table'
import type { Ref } from 'vue'
/**
* valueUpdater applies one TanStack Table `Updater<T>` — either a new
* value, or a function from the old value to the new one — to a plain Vue
* ref. This is the standard shadcn-vue glue between TanStack Table's
* callback-based state updates (`onSortingChange`, etc.) and Vue's
* ref-based reactivity (shadcn-vue docs: /docs/components/data-table).
*/
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>): void {
ref.value = typeof updaterOrValue === 'function' ? (updaterOrValue as (old: T) => T)(ref.value) : updaterOrValue
}
declare module '@tanstack/vue-table' {
interface ColumnMeta<TData extends RowData, TValue> {
/** Right-align this column's header button and cell content, with tabular-nums — set on numeric columns (DataTable.vue reads this). */
align?: 'right'
}
}
+75
View File
@@ -0,0 +1,75 @@
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import { formatCost, formatLimits } from '@/lib/format'
import type { AdminUsageEntryView } from '@/types/api'
/**
* numericColumn builds one right-aligned, sortable numeric column that
* reads '?' instead of a stale number while the entry's own scope has a
* down store — DataTable.vue's cell renderer, not the accessor, does this
* check, so sorting itself still uses the entry's real underlying value
* (a storeDown row sorts by its last-known number, same as the rest of
* the table; only the DISPLAYED text is masked). Mirrors the pre-DataTable
* UsageTable.vue template's own `entry.storeDown ? '?' : ...` convention.
*/
function numericColumn(
id: string,
header: string,
read: (entry: AdminUsageEntryView) => number,
format: (n: number) => string = String,
): ColumnDef<AdminUsageEntryView, unknown> {
return {
id,
header,
accessorFn: read,
meta: { align: 'right' },
cell: ({ row }) => (row.original.storeDown ? '?' : format(read(row.original))),
}
}
/**
* usageColumns builds the shared TanStack `ColumnDef` set for one
* AdminUsageEntryView table. Used by UsageTable.vue (the flat Users list,
* and — reused unmodified — a group's nested member-user list inside the
* Usage view's Groups accordion) and, headlessly (no `<Table>` render, see
* that view's own doc comment for why), by UsageView.vue's Groups sort
* bar, so both surfaces sort by the exact same column semantics.
* idLabel/secondaryColumnLabel name the first two columns; secondaryValue
* reads the second column's per-row value (a user's group name, or a
* group's member count).
*/
export function usageColumns(
idLabel: string,
secondaryColumnLabel: string,
secondaryValue: (entry: AdminUsageEntryView) => string,
): ColumnDef<AdminUsageEntryView, unknown>[] {
return [
{
id: 'id',
header: idLabel,
accessorFn: (entry) => entry.id,
cell: ({ row }) => h('span', { class: 'font-medium' }, row.original.id),
},
{
id: 'secondary',
header: secondaryColumnLabel,
accessorFn: secondaryValue,
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, secondaryValue(row.original)),
},
{
id: 'limits',
header: 'Limits',
accessorFn: (entry) => formatLimits(entry.limits),
cell: ({ row }) => h('span', { class: 'text-muted-foreground' }, formatLimits(row.original.limits)),
},
numericColumn('reqMin', 'req/min', (e) => e.requestsPerMinute),
numericColumn('reqDay', 'req/day', (e) => e.requestsPerDay),
numericColumn('tokInDay', 'tokIn/day', (e) => e.tokensInPerDay),
numericColumn('tokOutDay', 'tokOut/day', (e) => e.tokensOutPerDay),
numericColumn('tokInMonth', 'tokIn/month', (e) => e.tokensInPerMonth),
numericColumn('tokOutMonth', 'tokOut/month', (e) => e.tokensOutPerMonth),
numericColumn('costDay', 'cost/day', (e) => e.costPerDayMicroUsd, formatCost),
numericColumn('costMonth', 'cost/month', (e) => e.costPerMonthMicroUsd, formatCost),
]
}