mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
Reference in New Issue
Block a user