mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-17 05:37:43 +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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user