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
+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>