mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-15 05:29:28 +00:00
fix(webui): accessible warn token, day-window model badges, review minors
Review round (v0.22) — MUST-1, SHOULD-2's frontend half, and every folded minor that touched webui/. MUST-1 (contrast): the degraded badge's amber previously reused --color-chart-tokens-out, tuned for a chart mark, not body text — measured ~2.22:1 against --card in light mode, a WCAG 1.4.3 failure on exactly the "something's wrong" tier. A new semantic --status-warn token pair (main.css, with a @theme inline mapping) replaces it: #a35c00 light (~5.06:1), #f0ac1f dark (~8.97:1, already passing, redefined anyway so every semantic-color token's dark value lives in one place). ProviderRateBadge.vue's degraded tier now uses border-status-warn/40 text-status-warn. SHOULD-2 (frontend half — see the backend commit for admin.go/limits.go): AdminModelRateView drops attemptsMinute/failuresMinute (the backend no longer sends them at all). ProviderRateBadge.vue's attempts-minute/ failures-minute props become optional; their absence (not a passed- through 0) triggers a new dayRateTitle fallback (lib/provider-rate.ts) — a real "0 attempts this minute" and "no minute data fetched at all" read identically as 0 otherwise, which would misreport a model badge as having live-minute data it never measured. Folded minors: - formatRatePercent floors instead of rounds — only a true 100% rate (zero failures) ever renders "100%"; 99.6% now shows "99%", not a round-tripped "100%" that would overstate it. - providerSuccessRate NaN-guards both inputs (Number.isFinite). - ProvidersView's modelRateFor uses `p.modelRates?.[model]` — a stale client against an older server build could otherwise throw rather than fall back, matching its own doc comment's stated intent. - ProviderRateBadge's title is now also its aria-label — title alone is mouse-only; a keyboard/screen-reader user previously got none of the minute/day detail. - The accordion's "Last refresh" DETAIL row (not just the trigger's compact text) now shares refreshLabel's three-state logic via a new refreshDetailLabel (lib/format.ts) — it previously called formatTimestamp directly, so a discovery-off provider's expanded row still said "never" beside a trigger that correctly said "discovery off". - providerModelScopeID's Go doc comment (limits.go, backend commit) cross-references configNamePattern for why the "/" join is collision- safe even though a model id may itself contain "/". - useTabHash.ts documents its two intentional URL-normalization quirks: case-sensitive hash matching, and an invalid/absent initial hash normalizing only in memory, not in the address bar, until the next tab change.
This commit is contained in:
@@ -2,22 +2,30 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatRatePercent, minuteRateTitle, providerRateStatus, providerSuccessRate } from '@/lib/provider-rate'
|
||||
import { dayRateTitle, 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.
|
||||
* isModelDegraded). attemptsMinute/failuresMinute are OPTIONAL (SHOULD-2,
|
||||
* v0.22 review round): the provider-row call site passes them (admin.go's
|
||||
* adminProviderView carries minute counters); the per-model call site
|
||||
* does not (adminModelRateView no longer does — a model scope's minute
|
||||
* window is never fetched at all, see lib/provider-rate.ts's own
|
||||
* dayRateTitle). Their absence, not a passed-through 0, is what tells
|
||||
* this component to fall back to a day-window-only detail sentence — a
|
||||
* real 0-attempts minute and "no minute data at all" are different
|
||||
* things, and treating them as the identical "0" would silently claim a
|
||||
* live "0 attempts in the last minute" reading a model badge never
|
||||
* actually measured.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
attemptsDay: number
|
||||
failuresDay: number
|
||||
attemptsMinute: number
|
||||
failuresMinute: number
|
||||
attemptsMinute?: number
|
||||
failuresMinute?: number
|
||||
}>()
|
||||
|
||||
const rate = computed(() => providerSuccessRate(props.attemptsDay, props.failuresDay))
|
||||
@@ -25,28 +33,42 @@ 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))
|
||||
/**
|
||||
* detail carries the "right now" (provider) or "today" (model) counts —
|
||||
* the spec's minute-window requirement where that data exists at all
|
||||
* (minuteRateTitle), falling back to dayRateTitle when it does not. Used
|
||||
* as BOTH the badge's title (mouse hover) and its aria-label (folded
|
||||
* review minor, v0.22 review round: title alone is mouse-only — a
|
||||
* keyboard or screen-reader user gets none of it without an aria-label
|
||||
* carrying the same sentence).
|
||||
*/
|
||||
const detail = computed(() =>
|
||||
props.attemptsMinute === undefined || props.failuresMinute === undefined
|
||||
? dayRateTitle(props.attemptsDay, props.failuresDay)
|
||||
: 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".
|
||||
// `variant="secondary"`); degraded borrows the semantic --status-warn
|
||||
// token (main.css — MUST-1, v0.22 review round: --chart-tokens-out, this
|
||||
// component's original choice, measures ~2.22:1 against --card in light
|
||||
// mode, a WCAG 1.4.3 text-contrast failure on exactly the "something's
|
||||
// wrong" tier; --status-warn is tuned for body-text contrast instead,
|
||||
// ~5.06:1 light / ~8.97:1 dark).
|
||||
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 === 'degraded') return 'border-status-warn/40 text-status-warn'
|
||||
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">
|
||||
<Badge as="span" :variant="variant" :class="extraClass" class="font-normal tabular-nums" :title="detail" :aria-label="detail">
|
||||
{{ label }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { useSearchQuery } from '@/composables/useSearchQuery'
|
||||
import { formatAgo, formatTimestamp, refreshLabel, routableModelId } from '@/lib/format'
|
||||
import { formatAgo, refreshDetailLabel, 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'
|
||||
@@ -139,11 +139,20 @@ function visibleModels(p: AdminProviderView): string[] {
|
||||
}
|
||||
|
||||
/** 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 }
|
||||
const ZERO_MODEL_RATE: AdminModelRateView = { attemptsDay: 0, failuresDay: 0 }
|
||||
|
||||
/** modelRateFor looks up one model's counters within p.modelRates (Feature A, v0.22), falling back to ZERO_MODEL_RATE. */
|
||||
/**
|
||||
* modelRateFor looks up one model's counters within p.modelRates (Feature
|
||||
* A, v0.22), falling back to ZERO_MODEL_RATE. The optional-chain on
|
||||
* modelRates itself (folded review minor, v0.22 review round), not just
|
||||
* on the lookup, matches ZERO_MODEL_RATE's own doc comment: a stale
|
||||
* client talking to an older server build (before this field existed at
|
||||
* all) would otherwise throw on `.modelRates[model]` rather than fall
|
||||
* back — the type says modelRates is always present, but a real response
|
||||
* across a version skew is the one case that type cannot guarantee.
|
||||
*/
|
||||
function modelRateFor(p: AdminProviderView, model: string): AdminModelRateView {
|
||||
return p.modelRates[model] ?? ZERO_MODEL_RATE
|
||||
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. */
|
||||
@@ -283,7 +292,7 @@ const aliasEmptyMessage = computed(() =>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-muted-foreground">Last refresh</dt>
|
||||
<dd>{{ formatTimestamp(p.lastRefresh) }}</dd>
|
||||
<dd>{{ refreshDetailLabel(p.discoveryEnabled, p.lastRefresh) }}</dd>
|
||||
</div>
|
||||
<div v-if="p.lastErr">
|
||||
<dt class="text-xs text-muted-foreground">Last error</dt>
|
||||
@@ -293,12 +302,18 @@ const aliasEmptyMessage = computed(() =>
|
||||
<div v-if="visibleModels(p).length" class="flex flex-wrap gap-1.5">
|
||||
<span v-for="m in visibleModels(p)" :key="m" class="inline-flex items-center gap-1">
|
||||
<ModelChip :id="routableModelId(p.name, m)" />
|
||||
<!--
|
||||
attempts-minute/failures-minute deliberately omitted
|
||||
(SHOULD-2, v0.22 review round): AdminModelRateView no
|
||||
longer carries them at all, so leaving them unbound
|
||||
(undefined) is what tells ProviderRateBadge to render
|
||||
its day-window-only detail (dayRateTitle) instead of
|
||||
claiming a live minute-window reading no data backs.
|
||||
-->
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user