From 60d66ca147bebae97d950b6ddb1d51af4a255ad5 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Fri, 21 Aug 2026 11:12:59 +0100 Subject: [PATCH] feat(webui): shadcn-vue accordion + sortable DataTable, full-width panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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- 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 (Accordion wraps content in a
, illegal inside
), 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). --- webui/README.md | 15 +- webui/package-lock.json | 33 +++ webui/package.json | 1 + webui/src/App.vue | 2 +- webui/src/components/DataTable.vue | 114 +++++++ webui/src/components/OverviewView.vue | 278 +++++++----------- webui/src/components/UsageTable.vue | 90 ++---- webui/src/components/UsageView.vue | 155 +++++++++- .../src/components/ui/accordion/Accordion.vue | 27 ++ .../ui/accordion/AccordionContent.vue | 28 ++ .../components/ui/accordion/AccordionItem.vue | 24 ++ .../ui/accordion/AccordionTrigger.vue | 47 +++ webui/src/components/ui/accordion/index.ts | 4 + webui/src/components/ui/table/index.ts | 1 + webui/src/components/ui/table/utils.ts | 20 ++ webui/src/lib/usage-columns.ts | 75 +++++ 16 files changed, 666 insertions(+), 248 deletions(-) create mode 100644 webui/src/components/DataTable.vue create mode 100644 webui/src/components/ui/accordion/Accordion.vue create mode 100644 webui/src/components/ui/accordion/AccordionContent.vue create mode 100644 webui/src/components/ui/accordion/AccordionItem.vue create mode 100644 webui/src/components/ui/accordion/AccordionTrigger.vue create mode 100644 webui/src/components/ui/accordion/index.ts create mode 100644 webui/src/components/ui/table/utils.ts create mode 100644 webui/src/lib/usage-columns.ts diff --git a/webui/README.md b/webui/README.md index bdeaf3b..20b8fe7 100644 --- a/webui/README.md +++ b/webui/README.md @@ -71,11 +71,18 @@ npm run build # what `make admin-ui` runs, plus webui/generate.mjs (Overview/Usage, 5s poll), `history` (Charts, fetch-on-selection + 30s refresh). - `src/components/` — `AuthGate`, `OverviewView`, `UsageView`, - `ChartsView`/`UsageChart`, `UsageTable`; `ui/` holds the shadcn-vue - primitives this panel actually uses (Card, Table, Tabs, Button, Input, - Alert, Select, Badge) — nothing installed and unused. + `ChartsView`/`UsageChart`, `UsageTable`, `DataTable` (the shared + sortable-table wrapper — shadcn-vue's DataTable pattern, `@tanstack/ + vue-table`, generic over row type); `ui/` holds the shadcn-vue + primitives this panel actually uses (Accordion, Card, Table, Tabs, + Button, Input, Alert, Select, Badge) — nothing installed and unused. + Providers (Overview) and Groups (Usage) render as a real Accordion — + expanding a group shows its member users' own usage rows, a + client-side join on `AdminUsageEntryView.groupName`. - `src/lib/` — `api.ts` (the `/admin/api/*` fetch wrapper), `format.ts`, - `key-storage.ts`, `chart-setup.ts`. + `key-storage.ts`, `chart-setup.ts`, `provider-expand.ts` (search-aware + expand state, adapted to drive Accordion's v-model), `usage-columns.ts` + (shared `ColumnDef`s for every usage table). - `src/types/api.ts` — TypeScript mirrors of admin.go's JSON response shapes (`adminOverviewResponse`, `adminUsageResponse`, `usageHistoryResponse`). diff --git a/webui/package-lock.json b/webui/package-lock.json index c5faa42..b4322cb 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -11,6 +11,7 @@ "@fortawesome/fontawesome-svg-core": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/vue-fontawesome": "^3.3.3", + "@tanstack/vue-table": "^8.21.3", "@vueuse/core": "^14.4.0", "chart.js": "^4.5.1", "class-variance-authority": "^0.7.1", @@ -1753,6 +1754,19 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-core": { "version": "3.17.8", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.8.tgz", @@ -1763,6 +1777,25 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/vue-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.3.tgz", + "integrity": "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": ">=3.2" + } + }, "node_modules/@tanstack/vue-virtual": { "version": "3.13.36", "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.36.tgz", diff --git a/webui/package.json b/webui/package.json index 96db079..11c489c 100644 --- a/webui/package.json +++ b/webui/package.json @@ -12,6 +12,7 @@ "@fortawesome/fontawesome-svg-core": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/vue-fontawesome": "^3.3.3", + "@tanstack/vue-table": "^8.21.3", "@vueuse/core": "^14.4.0", "chart.js": "^4.5.1", "class-variance-authority": "^0.7.1", diff --git a/webui/src/App.vue b/webui/src/App.vue index 6cf3666..bfd4e56 100644 --- a/webui/src/App.vue +++ b/webui/src/App.vue @@ -32,7 +32,7 @@ onMounted(() => {