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,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>
|
||||
Reference in New Issue
Block a user