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
+67 -21
View File
@@ -2,9 +2,13 @@
import { computed } from 'vue'
import DataTable from '@/components/DataTable.vue'
import EmptyState from '@/components/EmptyState.vue'
import ErrorState from '@/components/ErrorState.vue'
import SearchInput from '@/components/SearchInput.vue'
import SkeletonTable from '@/components/SkeletonTable.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useSearchQuery } from '@/composables/useSearchQuery'
import { loadState } from '@/lib/load-state'
import { composeTargetId, targetColumns } from '@/lib/target-columns'
import type { TargetKind } from '@/lib/target-columns'
import { useDashboardStore } from '@/stores/dashboard'
@@ -62,6 +66,33 @@ function emptyMessage(configuredCount: number, filteredCount: number): string {
const mcpEmptyMessage = computed(() => emptyMessage(targets.value?.mcpServers.length ?? 0, filteredMCPServers.value.length))
const agentsEmptyMessage = computed(() => emptyMessage(targets.value?.agents.length ?? 0, filteredAgents.value.length))
/**
* targetsLoadState (lib/load-state.ts) — GET /admin/api/targets used to
* have NO loading/error handling at all here: `targets` mapped a
* not-yet-loaded/errored dashboard.targets straight to `[]` (`?? []`
* above), which DataTable then rendered as a confirmed, empty table —
* exactly the same class of bug lib/access-matrix.ts's matrixColumnsState
* fixed for the Access matrix (Task A). 'ready' once dashboard.targets
* has loaded at least once, even if both lists are genuinely empty —
* `noTargetsAtAll` below is the SEPARATE "loaded, but nothing configured"
* check, same split as matrixColumnsState's own loaded-vs-empty
* precedence.
*/
const targetsLoadState = computed(() => loadState({ loading: true, hasData: dashboard.targets !== null, error: dashboard.error }))
/**
* noTargetsAtAll (states-plan.md item 2: "MCP & Agents: 'No MCP servers
* or agents configured'") is true only once targets have genuinely
* loaded with BOTH lists empty — read off the raw configured counts, not
* the filtered ones, so an active search query never flips this true (a
* query matching nothing is `mcpEmptyMessage`/`agentsEmptyMessage`'s own
* "no targets match" case instead, a routine filter interaction, not a
* fleet-configuration fact worth the same combined empty-state).
*/
const noTargetsAtAll = computed(
() => targetsLoadState.value === 'ready' && (targets.value?.mcpServers.length ?? 0) === 0 && (targets.value?.agents.length ?? 0) === 0,
)
// Two separate column-def sets (lib/target-columns.ts) — MCP servers and
// Agents each close over their own fixed `kind` (see onSelect above) so a
// name click composes the right "mcp/…"/"agent/…" id. Stateless ColumnDef
@@ -75,30 +106,45 @@ const agentColumns = targetColumns(onSelect('agent'))
<div class="flex flex-col gap-6">
<SearchInput v-model="query" placeholder="Filter MCP servers or agents" class="max-w-sm" />
<Card>
<CardHeader>
<CardTitle>MCP servers</CardTitle>
<CardDescription>
Configured MCP proxy targets, the groups allowed to reach them, and their request counters. Click a name to see its
callers.
</CardDescription>
</CardHeader>
<Card v-if="noTargetsAtAll">
<CardContent>
<DataTable :columns="mcpColumns" :data="filteredMCPServers" :empty-message="mcpEmptyMessage" />
<EmptyState
title="No MCP servers or agents configured."
description="Add an mcpServers or agents entry to the middleware config to see them here."
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Agents</CardTitle>
<CardDescription>
Configured A2A agent proxy targets, the groups allowed to reach them, and their request counters. Click a name to
see its callers.
</CardDescription>
</CardHeader>
<CardContent>
<DataTable :columns="agentColumns" :data="filteredAgents" :empty-message="agentsEmptyMessage" />
</CardContent>
</Card>
<template v-else>
<Card>
<CardHeader>
<CardTitle>MCP servers</CardTitle>
<CardDescription>
Configured MCP proxy targets, the groups allowed to reach them, and their request counters. Click a name to see
its callers.
</CardDescription>
</CardHeader>
<CardContent>
<SkeletonTable v-if="targetsLoadState === 'skeleton'" :rows="3" :cols="mcpColumns.length" />
<ErrorState v-else-if="targetsLoadState === 'error'" :message="dashboard.error" />
<DataTable v-else :columns="mcpColumns" :data="filteredMCPServers" :empty-message="mcpEmptyMessage" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Agents</CardTitle>
<CardDescription>
Configured A2A agent proxy targets, the groups allowed to reach them, and their request counters. Click a name to
see its callers.
</CardDescription>
</CardHeader>
<CardContent>
<SkeletonTable v-if="targetsLoadState === 'skeleton'" :rows="3" :cols="agentColumns.length" />
<ErrorState v-else-if="targetsLoadState === 'error'" :message="dashboard.error" />
<DataTable v-else :columns="agentColumns" :data="filteredAgents" :empty-message="agentsEmptyMessage" />
</CardContent>
</Card>
</template>
</div>
</template>