mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
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:
+11
-4
@@ -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`).
|
||||
|
||||
Generated
+33
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 — Admin</h1>
|
||||
<p class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
|
||||
@@ -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>
|
||||
@@ -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 "{{ modelQuery }}"
|
||||
</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 "{{ modelQuery }}"
|
||||
</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 → 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 "{{ modelQuery }}"
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user