mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-15 05:29:28 +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:
@@ -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