mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-15 05:29:28 +00:00
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:
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatRatePercent, minuteRateTitle, providerRateStatus, providerSuccessRate } from '@/lib/provider-rate'
|
||||
|
||||
/**
|
||||
* ProviderRateBadge is the Providers tab's success-rate badge (Feature A,
|
||||
* v0.22) — reused for both a provider's own row (ProvidersView.vue's
|
||||
* accordion trigger, always shown) and a single degraded model beside its
|
||||
* ModelChip (ProvidersView.vue's accordion content, shown only when
|
||||
* isModelDegraded). Both call sites pass the identical four counters
|
||||
* (admin.go's adminProviderView / adminModelRateView field shape), so one
|
||||
* component covers both without a provider/model-specific prop.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
attemptsDay: number
|
||||
failuresDay: number
|
||||
attemptsMinute: number
|
||||
failuresMinute: number
|
||||
}>()
|
||||
|
||||
const rate = computed(() => providerSuccessRate(props.attemptsDay, props.failuresDay))
|
||||
const status = computed(() => providerRateStatus(rate.value))
|
||||
|
||||
const label = computed(() => (status.value === 'no-traffic' ? 'no traffic' : formatRatePercent(rate.value as number)))
|
||||
|
||||
/** title carries the minute-window "right now" detail (spec) regardless of tier — even a healthy badge's title shows the live minute count, not just the day-window percent the badge text itself displays. */
|
||||
const title = computed(() => minuteRateTitle(props.attemptsMinute, props.failuresMinute))
|
||||
|
||||
// Badge has no dedicated "amber"/"muted" variant (ui/badge/index.ts) —
|
||||
// severe reuses the built-in `destructive` variant exactly like every
|
||||
// other error state in this panel (e.g. ProvidersView's own lastErr
|
||||
// icon); healthy/no-traffic reuse `secondary`/`outline`, the panel's
|
||||
// existing "quiet" idioms (ProvidersView's provider-type badge is
|
||||
// `variant="secondary"`); degraded is the one tier with no built-in
|
||||
// variant, so it borrows the chart palette's amber token
|
||||
// (--color-chart-tokens-out, main.css) the same way the Redis/Cache/Retry
|
||||
// status cards above already borrow --color-chart-requests for "ok".
|
||||
const variant = computed(() => (status.value === 'severe' ? 'destructive' : status.value === 'healthy' ? 'secondary' : 'outline'))
|
||||
const extraClass = computed(() => {
|
||||
if (status.value === 'degraded') return 'border-chart-tokens-out/40 text-chart-tokens-out'
|
||||
if (status.value === 'no-traffic') return 'text-muted-foreground'
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge as="span" :variant="variant" :class="extraClass" class="font-normal tabular-nums" :title="title">
|
||||
{{ label }}
|
||||
</Badge>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@ import { computed, h, reactive, watch } from 'vue'
|
||||
|
||||
import DataTable from '@/components/DataTable.vue'
|
||||
import ModelChip from '@/components/ModelChip.vue'
|
||||
import ProviderRateBadge from '@/components/ProviderRateBadge.vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -20,10 +21,11 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { useSearchQuery } from '@/composables/useSearchQuery'
|
||||
import { formatAgo, formatTimestamp, routableModelId } from '@/lib/format'
|
||||
import { formatAgo, formatTimestamp, refreshLabel, routableModelId } from '@/lib/format'
|
||||
import { isModelDegraded } from '@/lib/provider-rate'
|
||||
import { type ExpandState, clearExpandOverrides, computeExpandedItems, toggleItemExpand } from '@/lib/search-expand'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import type { AdminAliasView, AdminProviderView } from '@/types/api'
|
||||
import type { AdminAliasView, AdminModelRateView, AdminProviderView } from '@/types/api'
|
||||
|
||||
// This component backs the "Providers" tab (App.vue) — a UI-label rename
|
||||
// only. The data it renders still comes from GET /admin/api/overview
|
||||
@@ -136,6 +138,20 @@ function visibleModels(p: AdminProviderView): string[] {
|
||||
return hasQuery.value ? p.models.filter((m) => modelMatches(p.name, m)) : p.models
|
||||
}
|
||||
|
||||
/** ZERO_MODEL_RATE is the fallback ProviderRateBadge reads for a model p.modelRates has no entry for — should not happen (admin.go's buildAdminOverview populates one entry per Models id, unconditionally), but a defensive fallback keeps a stale/mismatched client build from throwing rather than just under-reporting. */
|
||||
const ZERO_MODEL_RATE: AdminModelRateView = { attemptsDay: 0, failuresDay: 0, attemptsMinute: 0, failuresMinute: 0 }
|
||||
|
||||
/** modelRateFor looks up one model's counters within p.modelRates (Feature A, v0.22), falling back to ZERO_MODEL_RATE. */
|
||||
function modelRateFor(p: AdminProviderView, model: string): AdminModelRateView {
|
||||
return p.modelRates[model] ?? ZERO_MODEL_RATE
|
||||
}
|
||||
|
||||
/** modelIsDegraded gates the per-model rate badge (spec: "ONLY when that model is degraded") so a healthy or no-traffic model's ModelChip renders with no badge beside it at all. */
|
||||
function modelIsDegraded(p: AdminProviderView, model: string): boolean {
|
||||
const r = modelRateFor(p, model)
|
||||
return isModelDegraded(r.attemptsDay, r.failuresDay)
|
||||
}
|
||||
|
||||
// --- model aliases (sortable DataTable, operator directive) ---
|
||||
//
|
||||
// The alias id gets the ModelChip copy treatment (an alias name IS the
|
||||
@@ -244,13 +260,19 @@ const aliasEmptyMessage = computed(() =>
|
||||
<span class="font-medium">{{ p.name }}</span>
|
||||
<Badge as="span" variant="secondary" class="font-normal">{{ p.type }}</Badge>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">{{ p.modelCount }} models</span>
|
||||
<ProviderRateBadge
|
||||
:attempts-day="p.attemptsDay"
|
||||
:failures-day="p.failuresDay"
|
||||
:attempts-minute="p.attemptsMinute"
|
||||
:failures-minute="p.failuresMinute"
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
v-if="p.lastErr"
|
||||
:icon="faTriangleExclamation"
|
||||
class="size-3.5 shrink-0 text-destructive"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">refreshed {{ formatAgo(p.lastRefresh) || 'never' }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ refreshLabel(p.discoveryEnabled, p.lastRefresh) }}</span>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
@@ -269,7 +291,16 @@ const aliasEmptyMessage = computed(() =>
|
||||
</div>
|
||||
</dl>
|
||||
<div v-if="visibleModels(p).length" class="flex flex-wrap gap-1.5">
|
||||
<ModelChip v-for="m in visibleModels(p)" :key="m" :id="routableModelId(p.name, m)" />
|
||||
<span v-for="m in visibleModels(p)" :key="m" class="inline-flex items-center gap-1">
|
||||
<ModelChip :id="routableModelId(p.name, m)" />
|
||||
<ProviderRateBadge
|
||||
v-if="modelIsDegraded(p, m)"
|
||||
:attempts-day="modelRateFor(p, m).attemptsDay"
|
||||
:failures-day="modelRateFor(p, m).failuresDay"
|
||||
:attempts-minute="modelRateFor(p, m).attemptsMinute"
|
||||
:failures-minute="modelRateFor(p, m).failuresMinute"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<p v-else class="text-sm text-muted-foreground">no models known yet</p>
|
||||
</AccordionContent>
|
||||
|
||||
Reference in New Issue
Block a user