feat(webui): show per-replica upstream latency in the Providers tab

Add streaming and non-streaming latency badges beside the discovery
health badge on each provider row. Streaming shows average time to
first byte, labeled ttfb, since it is the only load-sensitive figure
this data provides. Non-streaming shows average total duration,
labeled avg, never latency or ttfb, because a non-streaming provider
buffers the whole completion before sending anything and its ttfb
carries the same output-length contamination as its duration.

Badges omit entirely when a provider has no observations yet, so
absence never reads as a fabricated zero. Every badge title states the
per-replica, in-process scope explicitly: this data comes from the
same in-process accumulator the rate-limit counters use, never Redis,
so a single poll reflects only whichever replica answered it.

Mirror admin.go's adminLatencyView type in types/api.ts, keyed by the
literal streaming/non-streaming union admin.go emits. Add
formatLatencyMs to lib/format.ts with spec tests alongside its
siblings in format.spec.ts.
This commit is contained in:
2026-08-23 18:55:16 +01:00
parent 35bb3dd62e
commit 3012586e8e
4 changed files with 162 additions and 3 deletions
+20 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { formatCompactCount, formatContextWindow, formatModelCostHover, formatUntil, refreshDetailLabel, refreshLabel } from './format'
import { formatCompactCount, formatContextWindow, formatLatencyMs, formatModelCostHover, formatUntil, refreshDetailLabel, refreshLabel } from './format'
const ZERO_TIME = '0001-01-01T00:00:00Z'
@@ -164,3 +164,22 @@ describe('formatCompactCount', () => {
expect(formatCompactCount(-15_000_000)).toBe('-15M')
})
})
// Latency panel (feat: instrument upstream latency, webui surface):
// avgTtfbMs/avgDurationMs arrive from admin.go as plain millisecond
// floats. formatLatencyMs is deliberately a plain rounding, not a
// floor-not-round boundary rule like formatCompactCount above — these are
// averages with no discrete threshold a reader could misinterpret as
// crossed.
describe('formatLatencyMs', () => {
it.each([
{ ms: 0, want: '0ms' },
{ ms: 42.7, want: '43ms' }, // sub-1000ms rounds to the nearest ms
{ ms: 999.4, want: '999ms' }, // just under the 1000ms/1s boundary
{ ms: 1000, want: '1.0s' }, // the boundary itself switches to seconds
{ ms: 1234, want: '1.2s' },
{ ms: 12_345, want: '12.3s' },
])('formatLatencyMs($ms) = $want', ({ ms, want }) => {
expect(formatLatencyMs(ms)).toBe(want)
})
})
+15
View File
@@ -147,6 +147,21 @@ export function formatModelCostHover(inputUsd: number, outputUsd: number): strin
return `in ${formatUsdPerMTok(inputUsd)} / out ${formatUsdPerMTok(outputUsd)} per MTok`
}
/**
* formatLatencyMs renders a millisecond average (admin.go's
* adminLatencyView.avgTtfbMs/avgDurationMs — feat: latency panel) as a
* compact duration: plain milliseconds below 1000 ("120ms"), one-decimal
* seconds at or above it ("1.2s"). These values are already averages
* (sum/count from the in-process latency accumulator), so rounding here
* carries none of formatCompactCount's boundary-crossing honesty concern
* above — there is no discrete threshold a caller could misread as
* crossed, just a plain rounded reading.
*/
export function formatLatencyMs(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`
return `${(ms / 1000).toFixed(1)}s`
}
/** formatAgo renders an ISO timestamp as "(Ns ago)", or "" for the unset zero-time sentinel. */
export function formatAgo(iso: string | undefined): string {
if (!iso || iso === ZERO_TIME) return ''