feat(admin): show target health in the dashboard

Add a Health column to the MCP servers and A2A agents tables in
TargetsView. An unhealthy target renders a destructive badge with the
age of its last check and carries the failure count, source, and
scrubbed last error in the title. An unknown target renders an outline
badge and, when it has failed but never succeeded, the same failure
details in its title. A healthy target renders muted text with its last
observed latency and no badge, matching the providers' convention.

Add AdminTargetHealthView to the API types with the wire contract the
Go side emits, a bare formatElapsedAgo helper next to formatAgo, and
column-cell tests for all three states. Regenerate the baked admin
assets.

Claude-Session: https://claude.ai/code/session_01EebV5jyWvYocByNQ3oss1w
This commit is contained in:
2026-09-03 02:49:55 +01:00
parent aad01b8d6f
commit af84ed5eb7
6 changed files with 373 additions and 17 deletions
+6 -6
View File
File diff suppressed because one or more lines are too long
+44 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { formatCompactCount, formatContextWindow, formatLatencyMs, formatModelCostHover, formatUntil, refreshDetailLabel, refreshLabel } from './format'
import { formatCompactCount, formatContextWindow, formatElapsedAgo, formatLatencyMs, formatModelCostHover, formatUntil, refreshDetailLabel, refreshLabel } from './format'
const ZERO_TIME = '0001-01-01T00:00:00Z'
@@ -183,3 +183,46 @@ describe('formatLatencyMs', () => {
expect(formatLatencyMs(ms)).toBe(want)
})
})
// Target health (feat/target-health): formatElapsedAgo is formatAgo's bare
// "Ns ago" counterpart — no parentheses — so a caller can compose it into
// either "unhealthy (Ns ago)" or "Ns ago via probe" without stripping
// punctuation back out. The explicit `now` parameter keeps every case
// below exact instead of racing Date.now() between computing the fixture
// and asserting on it.
describe('formatElapsedAgo', () => {
const now = new Date('2026-09-03T12:00:00Z').getTime()
it('renders "" for the unset zero-time sentinel', () => {
expect(formatElapsedAgo(ZERO_TIME, now)).toBe('')
})
it('renders "" for undefined', () => {
expect(formatElapsedAgo(undefined, now)).toBe('')
})
it('renders "" for an unparseable timestamp', () => {
expect(formatElapsedAgo('not-a-date', now)).toBe('')
})
it('renders "0s ago" for a timestamp equal to now', () => {
expect(formatElapsedAgo(new Date(now).toISOString(), now)).toBe('0s ago')
})
it('renders "Ns ago" for a timestamp seconds in the past', () => {
expect(formatElapsedAgo(new Date(now - 42_000).toISOString(), now)).toBe('42s ago')
})
it('renders whole elapsed seconds for a timestamp minutes in the past — no unit beyond seconds', () => {
expect(formatElapsedAgo(new Date(now - 90_000).toISOString(), now)).toBe('90s ago')
})
it('floors a future timestamp at 0s rather than showing a negative countdown (defensive — a clock-skewed lastCheck)', () => {
expect(formatElapsedAgo(new Date(now + 5_000).toISOString(), now)).toBe('0s ago')
})
it('defaults `now` to Date.now() when omitted', () => {
const iso = new Date(Date.now() - 5_000).toISOString()
expect(formatElapsedAgo(iso)).toMatch(/^\d+s ago$/)
})
})
+34 -5
View File
@@ -162,13 +162,42 @@ export function formatLatencyMs(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`
}
/**
* elapsedSecondsSince computes whole seconds between `iso` and `now`, or
* null for the unset zero-time sentinel or an unparseable value — the
* shared guard formatAgo and formatElapsedAgo below both build on, so
* neither duplicates the sentinel/NaN check.
*/
function elapsedSecondsSince(iso: string | undefined, now: number): number | null {
if (!iso || iso === ZERO_TIME) return null
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return null
return Math.max(0, Math.round((now - then) / 1000))
}
/** 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 ''
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return ''
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
return ` (${secs}s ago)`
const secs = elapsedSecondsSince(iso, Date.now())
return secs === null ? '' : ` (${secs}s ago)`
}
/**
* formatElapsedAgo renders elapsed time since an ISO timestamp as a bare
* "Ns ago" string — no surrounding parentheses, unlike formatAgo above
* (every existing formatAgo caller depends on its own " (Ns ago)" shape
* unchanged, so that convention stays untouched here). Target health
* badges (AdminTargetHealthView, TargetsView's health column — feat/
* target-health) are the only caller today: a target's `lastCheck` needs
* this bare form both standalone ("unhealthy (Ns ago)", parenthesized by
* the caller) and inline ("Ns ago via probe"). The optional `now`
* parameter (default Date.now()) makes the elapsed-time math
* deterministic under test. "" for the unset zero-time sentinel or an
* unparseable timestamp, mirroring formatAgo's own "nothing to show"
* convention.
*/
export function formatElapsedAgo(iso: string | undefined, now = Date.now()): string {
const secs = elapsedSecondsSince(iso, now)
return secs === null ? '' : `${secs}s ago`
}
/**
+158
View File
@@ -0,0 +1,158 @@
import { isVNode } from 'vue'
import { describe, expect, it } from 'vitest'
import { Badge } from '@/components/ui/badge'
import { targetColumns } from './target-columns'
import type { AdminTargetHealthView, AdminTargetView } from '@/types/api'
/**
* Target health rendering (feat/target-health): mirrors usage-columns.
* spec.ts's own approach of exercising a ColumnDef's `cell` renderer
* directly (no component mount needed — vite.config.ts's `test` block
* runs vitest in the 'node' environment with no @vue/test-utils/jsdom in
* this package, so there is no existing "mount a .vue view" pattern to
* follow here; the health column's rendering logic lives entirely in this
* plain TanStack ColumnDef, exactly like usage-columns.ts's own cells, so
* the identical direct-call approach gives full coverage without either).
*/
function testTarget(health: AdminTargetHealthView): AdminTargetView {
return {
name: 'my-server',
url: 'https://mcp.example.com',
counters: { requestsPerMinute: 0, requestsPerDay: 0, requestsPerMonth: 0 },
health,
}
}
function healthCellOf(target: AdminTargetView): unknown {
const col = targetColumns().find((c) => c.id === 'health')
const cell = col?.cell as (ctx: { row: { original: AdminTargetView } }) => unknown
return cell({ row: { original: target } })
}
describe('targetColumns: health column', () => {
it('renders a destructive Badge for unhealthy, with a relative-time label and lastError in the title', () => {
const health: AdminTargetHealthView = {
state: 'unhealthy',
lastCheck: new Date(Date.now() - 42_000).toISOString(),
lastError: 'connection refused',
consecutiveFailures: 3,
source: 'probe',
}
const rendered = healthCellOf(testTarget(health))
expect(isVNode(rendered)).toBe(true)
const vnode = rendered as { type: unknown; props?: Record<string, unknown>; children?: unknown }
expect(vnode.type).toBe(Badge)
expect(vnode.props?.variant).toBe('destructive')
expect(vnode.props?.title).toBe('3 consecutive failures via probe: connection refused')
const slot = (vnode.children as { default: () => string }).default
expect(slot()).toMatch(/^unhealthy \(\d+s ago\)$/)
})
it('falls back to a bare "unhealthy" label when lastCheck is missing (version-skew defensive case)', () => {
const health: AdminTargetHealthView = { state: 'unhealthy', consecutiveFailures: 1 }
const rendered = healthCellOf(testTarget(health))
const vnode = rendered as { props?: Record<string, unknown>; children?: { default: () => string } }
expect(vnode.children?.default()).toBe('unhealthy')
// Singular "failure", no "via <source>", no lastError.
expect(vnode.props?.title).toBe('1 consecutive failure')
})
it('renders an outline Badge labeled "unknown" with a fixed explanatory title', () => {
const health: AdminTargetHealthView = { state: 'unknown', consecutiveFailures: 0 }
const rendered = healthCellOf(testTarget(health))
const vnode = rendered as { type: unknown; props?: Record<string, unknown>; children?: { default: () => string } }
expect(vnode.type).toBe(Badge)
expect(vnode.props?.variant).toBe('outline')
expect(vnode.props?.title).toBe('not observed yet — no traffic and probes disabled or not yet run')
expect(vnode.children?.default()).toBe('unknown')
})
it('renders no Badge for healthy — plain muted text with latency, and relative time + source in the title', () => {
const health: AdminTargetHealthView = {
state: 'healthy',
lastCheck: new Date(Date.now() - 5_000).toISOString(),
consecutiveFailures: 0,
latencyMs: 12,
source: 'traffic',
}
const rendered = healthCellOf(testTarget(health))
expect(isVNode(rendered)).toBe(true)
const vnode = rendered as { type: unknown; props?: Record<string, unknown>; children?: unknown }
expect(vnode.type).toBe('span')
expect(vnode.props?.class).toBe('text-muted-foreground')
// formatLatencyMs (lib/format.ts), not a raw "${latencyMs} ms" — no
// space, matching every other latency reading in this panel.
expect(vnode.children).toBe('healthy · 12ms')
expect(vnode.props?.title).toMatch(/^\d+s ago via traffic$/)
})
it('renders the healthy latency suffix in seconds via formatLatencyMs once above 1000ms', () => {
const health: AdminTargetHealthView = { state: 'healthy', consecutiveFailures: 0, latencyMs: 1500 }
const rendered = healthCellOf(testTarget(health))
const vnode = rendered as { children?: unknown }
expect(vnode.children).toBe('healthy · 1.5s')
})
it('omits the latency suffix for healthy when latencyMs is unknown', () => {
const health: AdminTargetHealthView = { state: 'healthy', consecutiveFailures: 0 }
const rendered = healthCellOf(testTarget(health))
const vnode = rendered as { children?: unknown; props?: Record<string, unknown> }
expect(vnode.children).toBe('healthy')
// Neither lastCheck nor source known — nothing to show in the title.
expect(vnode.props?.title).toBe('')
})
it('the health column is not sortable — three semantic states have no meaningful alphabetical order', () => {
const col = targetColumns().find((c) => c.id === 'health')
expect(col?.enableSorting).toBe(false)
})
// F4/F7 (feat/target-health review): "unknown" also covers a target
// that HAS been observed but has never succeeded, still below
// failureThreshold — that case carries real failure detail even
// though the label stays "unknown".
it('surfaces failure detail in the unknown badge title for a target observed but never succeeded', () => {
const health: AdminTargetHealthView = {
state: 'unknown',
consecutiveFailures: 2,
source: 'probe',
lastError: 'dial tcp: connection refused',
}
const rendered = healthCellOf(testTarget(health))
const vnode = rendered as { type: unknown; props?: Record<string, unknown>; children?: { default: () => string } }
expect(vnode.type).toBe(Badge)
expect(vnode.props?.variant).toBe('outline')
expect(vnode.props?.title).toBe('2 failures so far via probe: dial tcp: connection refused')
expect(vnode.children?.default()).toBe('unknown')
})
it('singular "failure" and no "via <source>"/lastError when only consecutiveFailures is known', () => {
const health: AdminTargetHealthView = { state: 'unknown', consecutiveFailures: 1 }
const rendered = healthCellOf(testTarget(health))
const vnode = rendered as { props?: Record<string, unknown> }
expect(vnode.props?.title).toBe('1 failure so far')
})
it('falls back to the outline "unknown" badge for any state other than healthy/unhealthy', () => {
// Defensive: healthCell's own state check is an explicit
// state === 'healthy' branch with "unknown" as the fallback for
// everything else — not the reverse — so a value this version of
// the webui does not recognize renders as "unknown", never as a
// silently-wrong "healthy".
const health = { state: 'something-future-versions-might-add', consecutiveFailures: 0 } as unknown as AdminTargetHealthView
const rendered = healthCellOf(testTarget(health))
const vnode = rendered as { type: unknown; props?: Record<string, unknown>; children?: { default: () => string } }
expect(vnode.type).toBe(Badge)
expect(vnode.props?.variant).toBe('outline')
expect(vnode.children?.default()).toBe('unknown')
})
})
+92 -5
View File
@@ -3,7 +3,8 @@ import { h } from 'vue'
import CompactNumber from '@/components/CompactNumber.vue'
import { Badge } from '@/components/ui/badge'
import type { AdminTargetView } from '@/types/api'
import { formatElapsedAgo, formatLatencyMs } from '@/lib/format'
import type { AdminTargetHealthView, AdminTargetView } from '@/types/api'
/**
* numericColumn builds one right-aligned, sortable request-counter
@@ -27,16 +28,91 @@ function numericColumn(id: string, header: string, read: (t: AdminTargetView) =>
}
}
// --- target health (feat/target-health) ---
//
// Mirrors ProvidersView.vue's own healthBadgeVariant/healthBadgeLabel
// convention for the discovery circuit breaker: a Badge only for the two
// states worth flagging (unhealthy, unknown); the common healthy case
// renders no badge at all, just muted inline text — the same "nothing to
// show reads as no badge" idiom every health/rate indicator in this panel
// already follows (see AdminTargetHealthView's own doc comment, api.ts,
// for what each field means).
const UNKNOWN_HEALTH_TITLE = 'not observed yet — no traffic and probes disabled or not yet run'
/** unhealthyLabel keeps the Badge's own visible text short — "unhealthy (Ns ago)" when lastCheck is known, plain "unhealthy" otherwise (defensive: a version-skewed server response omitting lastCheck on an unhealthy row). */
function unhealthyLabel(health: AdminTargetHealthView): string {
const ago = formatElapsedAgo(health.lastCheck)
return ago ? `unhealthy (${ago})` : 'unhealthy'
}
/** unhealthyTitle carries the detail the short label leaves out — consecutiveFailures, source, and lastError (when present) — e.g. "3 consecutive failures via probe: connection refused". */
function unhealthyTitle(health: AdminTargetHealthView): string {
const failWord = health.consecutiveFailures === 1 ? 'failure' : 'failures'
const sourcePart = health.source ? ` via ${health.source}` : ''
const base = `${health.consecutiveFailures} consecutive ${failWord}${sourcePart}`
return health.lastError ? `${base}: ${health.lastError}` : base
}
/** healthyTitle is the muted "healthy" text's hover detail — "Ns ago via probe/traffic" — omitting whichever half (relative time, source) the response does not carry. */
function healthyTitle(health: AdminTargetHealthView): string {
const ago = formatElapsedAgo(health.lastCheck)
if (!ago) return health.source ? `via ${health.source}` : ''
return health.source ? `${ago} via ${health.source}` : ago
}
/**
* unknownTitle renders the "unknown" badge's hover detail. F4
* (feat/target-health) reads "unknown" two different ways — see
* AdminTargetHealthView's own doc comment (types/api.ts): never observed
* at all, which gets the generic UNKNOWN_HEALTH_TITLE, or observed but
* never yet succeeded (still below failureThreshold), which carries real
* failure detail (consecutiveFailures/source/lastError) even though the
* label stays "unknown" — this surfaces that detail instead of the
* generic message, mirroring unhealthyTitle's own conditional shape.
*/
function unknownTitle(health: AdminTargetHealthView): string {
if (health.consecutiveFailures <= 0 && !health.lastError) return UNKNOWN_HEALTH_TITLE
const failWord = health.consecutiveFailures === 1 ? 'failure' : 'failures'
const sourcePart = health.source ? ` via ${health.source}` : ''
const base = `${health.consecutiveFailures} ${failWord} so far${sourcePart}`
return health.lastError ? `${base}: ${health.lastError}` : base
}
/**
* healthCell renders one row's health column: destructive Badge for
* unhealthy, muted plain text (plus " · " and formatLatencyMs when
* latencyMs is known) for the explicit healthy case, and an outline
* "unknown" Badge as the FALLBACK for everything else — not the reverse
* — so a state value this webui build does not recognize renders as
* "unknown" rather than silently reading as healthy.
*/
function healthCell(health: AdminTargetHealthView) {
if (health.state === 'unhealthy') {
return h(
Badge,
{ as: 'span', variant: 'destructive', class: 'font-normal', title: unhealthyTitle(health) },
() => unhealthyLabel(health),
)
}
if (health.state === 'healthy') {
const latencyPart = health.latencyMs !== undefined ? ` · ${formatLatencyMs(health.latencyMs)}` : ''
return h('span', { class: 'text-muted-foreground', title: healthyTitle(health) }, `healthy${latencyPart}`)
}
return h(Badge, { as: 'span', variant: 'outline', class: 'font-normal', title: unknownTitle(health) }, () => 'unknown')
}
/**
* targetColumns builds the shared TanStack `ColumnDef` set both the MCP
* servers and the Agents DataTable in TargetsView.vue use (admin.go:
* adminTargetView — the two tables share one identical row shape). name is
* plain text (not the ModelChip copy treatment — a target name is not a
* routable model id); url is muted and truncated with a title attribute
* for the full value; access renders as a Badge per allowed group, or a
* muted "All groups" when unrestricted — the exact idiom UsageView.vue's
* own group Access block already established (Providers/Models/MCP
* servers/Agents dt/dd pairs there).
* for the full value; health is the Badge/muted-text pair healthCell above
* builds (feat/target-health); access renders as a Badge per allowed
* group, or a muted "All groups" when unrestricted — the exact idiom
* UsageView.vue's own group Access block already established (Providers/
* Models/MCP servers/Agents dt/dd pairs there).
*/
export function targetColumns(): ColumnDef<AdminTargetView, unknown>[] {
return [
@@ -57,6 +133,17 @@ export function targetColumns(): ColumnDef<AdminTargetView, unknown>[] {
row.original.url,
),
},
{
id: 'health',
header: 'Health',
// No natural sort order across three semantic states (healthy/
// unhealthy/unknown) — plain alphabetical would put "healthy" and
// "unhealthy" beside each other for the wrong reason. Same call the
// access column below makes for its own non-primitive shape.
enableSorting: false,
accessorFn: (t) => t.health.state,
cell: ({ row }) => healthCell(row.original.health),
},
{
id: 'access',
header: 'Access',
+39
View File
@@ -316,6 +316,43 @@ export interface AdminTargetCountersView {
requestsPerMonth: number
}
/**
* One configured MCP server's or A2A agent's current health, matching the
* GET /admin/api/targets `health` object the Go side builds against
* (feat/target-health). Two independent sources feed `state`, never
* conflated: 'traffic' is PASSIVE — recorded from the target's own real
* proxied/federated request traffic as it happens, no extra requests
* sent; 'probe' is the gateway's own lazy health check, triggered
* opportunistically by an operator hitting /metrics, /admin, or a listing
* endpoint (never a background poller). `state` is 'unhealthy' once at
* least the configured `failureThreshold` consecutive failures have been
* observed (whether or not this target has ever succeeded), 'healthy'
* once it has succeeded at least once and stays below that threshold, and
* 'unknown' otherwise. `consecutiveFailures` is always present.
*
* 'unknown' reads two different ways server-side, and only ONE of them
* omits the rest of this object (F4, feat/target-health review):
* - never observed by either source at all: every other field is
* omitted — there is truly nothing to report.
* - observed, but never once succeeded, still below `failureThreshold`:
* every other field is PRESENT — `lastCheck`/`lastError`/`latencyMs`/
* `source` all carry a real observation, this target simply has not
* answered successfully yet. Do not treat 'unknown' as "no data" for
* this case; check whether `lastCheck` is set instead.
*
* `lastError` is additionally omitted whenever it is empty (a healthy
* target has nothing to show), and `latencyMs`/`source` are omitted only
* when genuinely not known (the never-observed case above).
*/
export interface AdminTargetHealthView {
state: 'healthy' | 'unhealthy' | 'unknown'
lastCheck?: string
lastError?: string
consecutiveFailures: number
latencyMs?: number
source?: 'probe' | 'traffic'
}
/**
* One configured MCP server's or A2A agent's row in GET /admin/api/targets
* (admin.go: adminTargetView). Access is the group names actually allowed
@@ -329,6 +366,8 @@ export interface AdminTargetView {
url: string
access?: string[]
counters: AdminTargetCountersView
/** See AdminTargetHealthView's own doc comment (feat/target-health) — always present, unlike access above. */
health: AdminTargetHealthView
}
/** GET /admin/api/targets (admin.go: adminTargetsResponse). */