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
+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),
]
}