feat(webui): rate badges, refresh labels, anchor links

Providers tab (Feature A): a new ProviderRateBadge component renders
each provider's day-window success rate (attempts-failures)/attempts —
always shown when attempts>0 (100% muted, <99% amber via the chart
palette's --color-chart-tokens-out token, <90% destructive, reusing the
Badge component's existing secondary/outline/destructive idioms rather
than inventing a new variant), a distinct "no traffic" badge when
attempts=0, and the live minute-window count in its title attribute.
The same component renders beside a ModelChip inside a provider's
accordion, but only when that model is degraded (<99% with attempts>0)
so a long, healthy model list stays uncluttered. The math and its
threshold constants live in lib/provider-rate.ts, unit-tested directly
(vitest's node environment needs no component mount).

Feature B: a provider's "refreshed ..." label (lib/format.ts's new
refreshLabel) now reads "discovery off" when DiscoveryEnabled is false,
"pending" when it is true but lastRefresh is still unset, and otherwise
the unchanged relative-time text — replacing the misleading "refreshed
never" a discovery-disabled provider showed before.

Feature C: composables/useTabHash syncs App.vue's active tab to the URL
hash (#providers/#usage/#charts/#targets, matching the existing
TabsTrigger values) with no router dependency. On load it reads the
current hash (falling back to "providers" for an absent or invalid one);
on tab change it writes the hash back via history.replaceState (no
scroll jump, no new history entry per click); a hashchange listener
handles back/forward and hand-edited URLs. hashToTab/tabToHash are
exported as pure functions so the mapping is unit-tested directly,
independent of window/history/location — every DOM touch inside
useTabHash itself is guarded behind one hasWindow check so the module
still imports cleanly under vitest's node test environment.
AuthGate never reads or writes the hash, so a tab named in the URL
survives the unauthenticated -> authenticated transition unchanged.
This commit is contained in:
2026-08-21 23:23:21 +01:00
parent bf23b41568
commit 2298249df5
10 changed files with 512 additions and 6 deletions
+12 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { faGithub } from '@fortawesome/free-brands-svg-icons'
import { faCircleExclamation } from '@fortawesome/free-solid-svg-icons'
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
import { computed, defineAsyncComponent, onMounted } from 'vue'
import AuthGate from '@/components/AuthGate.vue'
import ProvidersView from '@/components/ProvidersView.vue'
@@ -9,6 +9,7 @@ import TargetsView from '@/components/TargetsView.vue'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import UsageView from '@/components/UsageView.vue'
import { useTabHash } from '@/composables/useTabHash'
import { useAuthStore } from '@/stores/auth'
import { useDashboardStore } from '@/stores/dashboard'
@@ -29,7 +30,16 @@ const dashboard = useDashboardStore()
// fetches from, GET /admin/api/overview, and the store's own `overview`
// field (AdminOverviewResponse) keep their original names unchanged — the
// rename is UI-only, not an API surface change.
const activeView = ref<'providers' | 'usage' | 'charts' | 'targets'>('providers')
const TAB_VALUES = ['providers', 'usage', 'charts', 'targets'] as const
type TabValue = (typeof TAB_VALUES)[number]
// Feature C (v0.22): activeView is now hash-backed (composables/
// useTabHash.ts) instead of a bare ref — same v-model contract Tabs
// already expects, so nothing else about this template changes. AuthGate's
// own flow is untouched: it never reads or writes the hash, so whatever
// tab the URL named survives straight through the unauthenticated ->
// authenticated transition with no extra wiring here.
const activeView = useTabHash<TabValue>({ validTabs: TAB_VALUES, defaultTab: 'providers' })
const statusText = computed<string>(() => {
if (dashboard.error) return `refresh failed: ${dashboard.error}`