feat(webui): make the dashboard responsive and add loading and empty states

Every tab row scrolls sideways instead of being clipped by its card.
On phones this hid tabs, such as the Config change helper's last forms
at 360-390px. The login button has a larger touch target, and long
names are truncated with a tooltip.

Loading and empty states:
- Skeletons show only while a selection has no data yet, never over
  data already on screen during a refresh.
- Empty states name the next step to take.
- Error states offer a Retry button that shows progress, and each card
  keeps its own error.
- Toasts confirm copies and key generation, and report background
  failures once per streak through polite and assertive live regions.

Other changes:
- The access matrix switches between Models, MCP servers and Agents,
  and says "none configured" only once its data has loaded.
- Home ranks the top 5 users by cost, requests or tokens. It requests
  only the needed metrics and warns before a range exceeds the
  server's key cap.
- MCP and agent deep links accept a bare target name.

admin_assets_gen.go is regenerated.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 23:02:22 +01:00
parent 95a3a0e235
commit cf308624af
57 changed files with 3621 additions and 446 deletions
+27
View File
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
import { AdminApiError, adminFetch } from '@/lib/api'
import { createVisibilityPoller, type VisibilityPoller } from '@/lib/polling'
import { useAuthStore } from '@/stores/auth'
import { useToastsStore } from '@/stores/toasts'
import type { AdminOverviewResponse, AdminTargetsResponse, AdminUsageResponse } from '@/types/api'
/** How often the Providers/Usage/Targets views poll (matches the replaced vanilla-JS page). */
@@ -39,6 +40,8 @@ export const useDashboardStore = defineStore('dashboard', {
* so the dashboard never resorts to comparing response ordering.
*/
refreshing: false,
/** toastedForFailure guards states-plan.md item 3's "one toast per failure streak until it recovers" — set once a background-refresh-failure toast has fired for the CURRENT ongoing failure streak, reset the moment a refresh fully succeeds again (failures.length === 0 in doRefresh). */
toastedForFailure: false,
}),
actions: {
async refresh(): Promise<void> {
@@ -54,6 +57,16 @@ export const useDashboardStore = defineStore('dashboard', {
},
/** doRefresh is refresh()'s actual body, split out so the in-flight guard above wraps it in one place rather than every early-return branch below needing its own `finally`. */
async doRefresh(): Promise<void> {
// Captured BEFORE this tick's results are assigned below — overview/
// usage/targets are never cleared on a failed fetch (only ever
// assigned on a FULFILLED result), so this is a true "did the reader
// already have something on screen before this attempt" read, not
// just "did the previous refresh fully succeed" (a first attempt
// that partially succeeds still advances lastUpdated below, which
// would otherwise make a same-tick check on `lastUpdated` see this
// attempt's own partial success instead of the PRIOR state).
const hadDataBefore = this.overview !== null || this.usage !== null || this.targets !== null
const [overviewResult, usageResult, targetsResult] = await Promise.allSettled([
adminFetch<AdminOverviewResponse>('/admin/api/overview'),
adminFetch<AdminUsageResponse>('/admin/api/usage'),
@@ -78,6 +91,7 @@ export const useDashboardStore = defineStore('dashboard', {
if (failures.length === 0) {
this.lastUpdated = new Date()
this.error = ''
this.toastedForFailure = false
return
}
@@ -96,6 +110,19 @@ export const useDashboardStore = defineStore('dashboard', {
this.error = failures
.map(({ label, reason }) => `${label}: ${reason instanceof Error ? reason.message : String(reason)}`)
.join('; ')
// states-plan.md item 3: a background refresh failure while data is
// ALREADY shown gets a toast instead of wiping content (the header's
// own inline `error` text already covers that case too, but a toast
// surfaces it even when the reader is looking at a different page
// than the one showing the stale data) — never fired on a genuine
// first-load failure (hadDataBefore false), which is an ErrorState's
// job instead, and never repeated on every tick of an ONGOING
// failure streak (toastedForFailure).
if (hadDataBefore && !this.toastedForFailure) {
useToastsStore().push({ kind: 'error', message: `Dashboard refresh failed: ${this.error}` })
}
this.toastedForFailure = true
},
/**
* startPolling is idempotent and safe to call before a key is stored —