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
+40 -12
View File
@@ -8,7 +8,9 @@ import AuthGate from '@/components/AuthGate.vue'
import ConfigWarningsBanner from '@/components/ConfigWarningsBanner.vue'
import GlobalFilterBar from '@/components/GlobalFilterBar.vue'
import SidebarNav from '@/components/SidebarNav.vue'
import ToastViewport from '@/components/ToastViewport.vue'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useHashState } from '@/composables/useHashState'
import type { PageId } from '@/lib/pages'
import { useAuthStore } from '@/stores/auth'
@@ -56,20 +58,25 @@ const currentPage = computed<Component>(() => PAGES[nav.page])
// statusText reads dashboard.error/lastUpdated unconditionally — it is
// correct only while the template's own `v-if="auth.isAuthenticated"`
// below keeps it off-screen otherwise, so a first load with no stored
// key never shows a stale "refresh failed"/"loading..." line under the
// auth gate. Appended only on a genuinely successful poll —
// dashboard.overview.replica reflects WHICH replica answered that poll,
// so it must never be shown alongside a stale value during a failed or
// still-loading refresh.
// key never shows a stale "refresh failed" line under the auth gate.
// Appended only on a genuinely successful poll — dashboard.overview.
// replica reflects WHICH replica answered that poll, so it must never be
// shown alongside a stale value during a failed or still-loading refresh.
// The unreachable 'loading...' fallback (verify-ui-states.md #5 fix) was
// dead code: isInitialStatusLoad below covers exactly
// `!dashboard.error && !dashboard.lastUpdated`, the ONLY state in which
// neither of this function's two `if` branches fires, and the template
// renders the Skeleton for that state instead of ever reading statusText.
const statusText = computed<string>(() => {
if (dashboard.error) return `refresh failed: ${dashboard.error}`
if (dashboard.lastUpdated) {
const replica = dashboard.overview?.replica
return `last updated ${dashboard.lastUpdated.toLocaleTimeString()}${replica ? ` · replica ${replica}` : ''}`
}
return 'loading...'
const replica = dashboard.overview?.replica
const updated = dashboard.lastUpdated?.toLocaleTimeString() ?? ''
return `last updated ${updated}${replica ? ` · replica ${replica}` : ''}`
})
/** isInitialStatusLoad gates the header status line's skeleton — the FIRST dashboard poll only (states-plan.md item 1: a skeleton only when there is no data yet, never on a background refresh — dashboard.lastUpdated stays set through every later poll, success or failure). */
const isInitialStatusLoad = computed<boolean>(() => !dashboard.error && !dashboard.lastUpdated)
onMounted(() => {
dashboard.startPolling()
})
@@ -93,9 +100,29 @@ onMounted(() => {
<FontAwesomeIcon :icon="faGithub" class="size-4" aria-hidden="true" />
</Button>
</div>
<!--
No role="status" here (verify-ui-states.md #5 fix) — this line
updates every 5s (dashboard.ts's POLL_MS), and a live region
re-announces its FULL text on every single change; a screen
reader would have read "last updated HH:MM:SS" once every 5
seconds for as long as the session stayed open. A background
refresh FAILURE (the one change actually worth announcing) is
already a "meaningful change" a screen reader hears from
elsewhere: stores/dashboard.ts's own toast (states-plan.md item 3,
"background refresh failures when data is already shown") fires
exactly once per failure streak, through ToastViewport's own
assertive live region — a second, duplicate announcement here
would only repeat it.
-->
<p v-if="auth.isAuthenticated" class="flex items-center gap-1.5 text-sm text-muted-foreground">
<FontAwesomeIcon v-if="dashboard.error" :icon="faCircleExclamation" class="size-3.5 text-destructive" aria-hidden="true" />
<span :class="dashboard.error ? 'text-destructive' : undefined">{{ statusText }}</span>
<template v-if="isInitialStatusLoad">
<Skeleton class="h-4 w-40" />
<span class="sr-only">Loading dashboard status…</span>
</template>
<template v-else>
<FontAwesomeIcon v-if="dashboard.error" :icon="faCircleExclamation" class="size-3.5 text-destructive" aria-hidden="true" />
<span :class="dashboard.error ? 'text-destructive' : undefined">{{ statusText }}</span>
</template>
</p>
<GlobalFilterBar v-if="auth.isAuthenticated" />
</header>
@@ -116,4 +143,5 @@ onMounted(() => {
</main>
</div>
</div>
<ToastViewport />
</template>