From 26a24bb6cea7e7bfdbf0bb819113db80c800967d Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:41:53 +0100 Subject: [PATCH] feat(web,hub,cli): show machine health in session sidebar (#962) * feat(web,hub,cli): show machine load in session sidebar Runners attach OS health snapshots to machine-alive heartbeats; the hub caches them and the web session list renders load or CPU between the machine label and session count. Co-authored-by: Cursor * fix(web,cli): show CPU and RAM pressure in machine health badge Sidebar label now combines CPU and RAM percentages for overload signaling; load stays in the tooltip on Unix. Prime CPU sampling so the first heartbeat includes usage, not just memory. Co-authored-by: Cursor * feat(web): visual machine health meters with tooltip Replace bare CPU/RAM text with labeled mini bar gauges, chip border tint by severity, and a HoverTooltip explaining capacity and overload guidance. Co-authored-by: Cursor * fix(web): widen machine health tooltip with horizontal layout Allow a generous popover width and lay CPU/RAM/load out side by side so the capacity tooltip reads wider and less tall than the chip. Co-authored-by: Cursor * fix(web): anchor machine health tooltip to row left edge Wide tooltip was align=end on the chip, so it grew left off-screen. Use row-span positioning on the machine tile button instead. Co-authored-by: Cursor * feat(web): machine host card with OS label and inline health Turn the session sidebar machine row into a bordered host panel with OS metadata and side-by-side CPU/RAM meters embedded in the tile instead of a flat label line matching project rows. Co-authored-by: Cursor * fix(web): keep machine host tile single-row height Collapse the machine header back to one py-1.5 row with OS and compact inline health beside the name, and restore the original project indent without the extra nested rail or second header line. Co-authored-by: Cursor * feat(web): show CPU core count in machine health tooltip When the runner reports cpuCount, the tooltip reads "CPU across all 6 cores" instead of the generic all-cores label. Co-authored-by: Cursor * docs: add machine health sidebar screenshots Dogfood captures for the session sidebar machine tile and capacity tooltip, for upstream PR review. Co-authored-by: Cursor * fix(cli): clear machine-alive priming timeout on disconnect Track the 50ms CPU priming setTimeout and clear it in stopKeepAlive so disconnect/shutdown during the delay cannot leave a stray interval alive. Co-authored-by: Cursor * chore: drop dogfood screenshots from upstream PR diff Review evidence lives in the PR discussion only; no need to ship PNGs in the repo long-term. Co-authored-by: Cursor * fix(web): truncate long machine OS/host metadata in sidebar row Bound the metadata span so a long hostname cannot push the health chip or session count off-screen in narrow sidebars. Co-authored-by: Cursor * fix(web): reveal machine health tooltip on keyboard row focus Wire MACHINE_ROW_TOOLTIP_FOCUS_CLASS and aria-describedby on the machine header button so keyboard users can read the health tooltip like session rows. Co-authored-by: Cursor * fix(cli): use MemAvailable for Linux RAM pressure on Bun Bun's os.freemem() reflects MemFree (~1% on cache-heavy hosts), which made sidebar RAM read ~99% while btop showed ~40% used. Parse /proc/meminfo MemAvailable instead so used percent matches operator tools. Co-authored-by: Cursor * feat(web,cli): show machine uptime in sidebar tiles and tooltip Collect os.uptime() as uptimeSeconds on keepalive and render compact up 1h 54m in the machine meta row plus an Uptime line in the health tooltip. Co-authored-by: Cursor * fix(web): anchor machine health tooltip to chip not row align=row positioned the tooltip below the full machine header button, so the collapsible project panel painted over it on hover. Use align=end with a min-width panel so mouse and keyboard tooltips stay visible. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- cli/src/api/apiMachine.test.ts | 59 ++++++ cli/src/api/apiMachine.ts | 20 +- cli/src/utils/machineHealth.test.ts | 39 ++++ cli/src/utils/machineHealth.ts | 142 +++++++++++++ .../socket/handlers/cli/machineHandlers.ts | 1 + hub/src/socket/server.ts | 2 +- hub/src/sync/aliveEvents.test.ts | 49 +++++ hub/src/sync/machineCache.ts | 47 ++++- hub/src/sync/syncEngine.ts | 2 +- shared/src/schemas.ts | 14 +- shared/src/socket.ts | 2 +- shared/src/types.ts | 1 + web/src/components/HoverTooltip.tsx | 19 +- .../components/MachineGroupHeader.test.tsx | 80 +++++++ web/src/components/MachineGroupHeader.tsx | 133 ++++++++++++ .../MachineHealthIndicator.test.tsx | 50 +++++ web/src/components/MachineHealthIndicator.tsx | 181 ++++++++++++++++ web/src/components/SessionList.tsx | 52 ++--- web/src/lib/locales/en.ts | 21 ++ web/src/lib/locales/zh-CN.ts | 21 ++ web/src/lib/machineHealth.test.ts | 102 +++++++++ web/src/lib/machineHealth.ts | 198 ++++++++++++++++++ web/src/router.tsx | 8 + web/src/types/api.ts | 1 + 24 files changed, 1194 insertions(+), 50 deletions(-) create mode 100644 cli/src/utils/machineHealth.test.ts create mode 100644 cli/src/utils/machineHealth.ts create mode 100644 web/src/components/MachineGroupHeader.test.tsx create mode 100644 web/src/components/MachineGroupHeader.tsx create mode 100644 web/src/components/MachineHealthIndicator.test.tsx create mode 100644 web/src/components/MachineHealthIndicator.tsx create mode 100644 web/src/lib/machineHealth.test.ts create mode 100644 web/src/lib/machineHealth.ts diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index 5af3186c..321990f0 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -144,3 +144,62 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { } }) }) + +describe('ApiMachineClient keepAlive lifecycle', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('clears priming timeout on shutdown before first machine-alive emit', () => { + const machine = makeMachine('machine-keepalive') + const client = new ApiMachineClient('cli-token', machine) + const emit = vi.fn() + ;(client as unknown as { socket: { emit: typeof emit; close: () => void } }).socket = { + emit, + close: vi.fn(), + } as never + + const priv = client as unknown as { + startKeepAlive: () => void + keepAliveInterval: NodeJS.Timeout | null + keepAliveStartTimeout: ReturnType | null + } + + priv.startKeepAlive() + client.shutdown() + vi.advanceTimersByTime(100) + + expect(emit).not.toHaveBeenCalled() + expect(priv.keepAliveInterval).toBeNull() + expect(priv.keepAliveStartTimeout).toBeNull() + }) + + it('clears running keepAlive interval on shutdown', () => { + const machine = makeMachine('machine-keepalive-2') + const client = new ApiMachineClient('cli-token', machine) + const emit = vi.fn() + ;(client as unknown as { socket: { emit: typeof emit; close: () => void } }).socket = { + emit, + close: vi.fn(), + } as never + + const priv = client as unknown as { + startKeepAlive: () => void + keepAliveInterval: NodeJS.Timeout | null + } + + priv.startKeepAlive() + vi.advanceTimersByTime(50) + expect(emit).toHaveBeenCalledTimes(1) + + client.shutdown() + vi.advanceTimersByTime(20_000) + + expect(emit).toHaveBeenCalledTimes(1) + expect(priv.keepAliveInterval).toBeNull() + }) +}) diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 88ba3596..4225ff6a 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -25,6 +25,7 @@ import { import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes' import { applyVersionedAck } from './versionedUpdate' import { buildSocketIoExtraHeaderOptions } from './hubExtraHeaders' +import { collectMachineHealth } from '@/utils/machineHealth' type MachineRpcHandlers = { spawnSession: (options: SpawnSessionOptions) => Promise @@ -73,6 +74,7 @@ function formatWorkspaceRoots(paths?: string[]): string { export class ApiMachineClient { private socket!: Socket private keepAliveInterval: NodeJS.Timeout | null = null + private keepAliveStartTimeout: ReturnType | null = null private rpcHandlerManager: RpcHandlerManager private readonly normalizedWorkspaceRoots: string[] | undefined @@ -489,15 +491,27 @@ export class ApiMachineClient { private startKeepAlive(): void { this.stopKeepAlive() - this.keepAliveInterval = setInterval(() => { + const emitAlive = () => { this.socket.emit('machine-alive', { machineId: this.machine.id, - time: Date.now() + time: Date.now(), + health: collectMachineHealth() }) - }, 20_000) + } + // Prime CPU sampling so the first heartbeat already includes CPU %. + collectMachineHealth() + this.keepAliveStartTimeout = setTimeout(() => { + this.keepAliveStartTimeout = null + emitAlive() + this.keepAliveInterval = setInterval(emitAlive, 20_000) + }, 50) } private stopKeepAlive(): void { + if (this.keepAliveStartTimeout) { + clearTimeout(this.keepAliveStartTimeout) + this.keepAliveStartTimeout = null + } if (this.keepAliveInterval) { clearInterval(this.keepAliveInterval) this.keepAliveInterval = null diff --git a/cli/src/utils/machineHealth.test.ts b/cli/src/utils/machineHealth.test.ts new file mode 100644 index 00000000..bdfb2070 --- /dev/null +++ b/cli/src/utils/machineHealth.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { collectMachineHealth, readLinuxMemoryUsedPercent, resetMachineHealthSamplerForTests } from './machineHealth' + +describe('readLinuxMemoryUsedPercent', () => { + it('uses MemAvailable, not MemFree, so page cache does not read as pressure', () => { + const meminfo = ` +MemTotal: 32793696 kB +MemFree: 578248 kB +MemAvailable: 18312444 kB +Buffers: 1196580 kB +Cached: 9758076 kB +`.trim() + + expect(readLinuxMemoryUsedPercent(meminfo)).toBe(44) + }) +}) + +describe('collectMachineHealth', () => { + it('returns schema-valid health with memory, uptime, and cpu count', () => { + resetMachineHealthSamplerForTests() + const health = collectMachineHealth(1_700_000_000_000) + expect(health.collectedAt).toBe(1_700_000_000_000) + expect(health.cpuCount).toBeGreaterThan(0) + expect(health.memoryPercent).toBeGreaterThanOrEqual(0) + expect(health.memoryPercent).toBeLessThanOrEqual(100) + expect(health.uptimeSeconds).toBeGreaterThan(0) + }) + + it('computes cpu percent after a second sample', async () => { + resetMachineHealthSamplerForTests() + collectMachineHealth() + await new Promise((resolve) => setTimeout(resolve, 50)) + const second = collectMachineHealth() + if (second.cpuPercent !== undefined) { + expect(second.cpuPercent).toBeGreaterThanOrEqual(0) + expect(second.cpuPercent).toBeLessThanOrEqual(100) + } + }) +}) diff --git a/cli/src/utils/machineHealth.ts b/cli/src/utils/machineHealth.ts new file mode 100644 index 00000000..53d837e2 --- /dev/null +++ b/cli/src/utils/machineHealth.ts @@ -0,0 +1,142 @@ +import { readFileSync } from 'node:fs' +import { availableParallelism, cpus, freemem, loadavg, platform, totalmem, uptime } from 'node:os' +import type { MachineHealth } from '@hapi/protocol/types' +import { MachineHealthSchema } from '@hapi/protocol/schemas' + +type CpuTimesSnapshot = { + idle: number + total: number +} + +let previousCpuSnapshot: CpuTimesSnapshot | null = null + +function sumCpuTimes(): CpuTimesSnapshot | null { + const cores = cpus() + if (cores.length === 0) { + return null + } + + let idle = 0 + let total = 0 + for (const core of cores) { + const times = core.times + idle += times.idle + total += times.user + times.nice + times.sys + times.idle + times.irq + } + + return { idle, total } +} + +function computeCpuPercent(current: CpuTimesSnapshot, previous: CpuTimesSnapshot): number | undefined { + const idleDelta = current.idle - previous.idle + const totalDelta = current.total - previous.total + if (totalDelta <= 0) { + return undefined + } + + const usage = 1 - idleDelta / totalDelta + return Math.max(0, Math.min(100, Math.round(usage * 100))) +} + +function parseMeminfoKbValue(meminfo: string, key: string): number | undefined { + for (const line of meminfo.split('\n')) { + if (!line.startsWith(`${key}:`)) { + continue + } + const kb = Number(line.split(/\s+/)[1]) + return Number.isFinite(kb) ? kb * 1024 : undefined + } + return undefined +} + +/** Linux pressure percent: (MemTotal - MemAvailable) / MemTotal. Testable without /proc. */ +export function readLinuxMemoryUsedPercent(meminfo: string): number | undefined { + const total = parseMeminfoKbValue(meminfo, 'MemTotal') + if (!total || total <= 0) { + return undefined + } + + const available = parseMeminfoKbValue(meminfo, 'MemAvailable') + if (available !== undefined) { + return Math.max(0, Math.min(100, Math.round(((total - available) / total) * 100))) + } + + // Pre-3.14 kernels: approximate available as free + reclaimable cache. + const free = parseMeminfoKbValue(meminfo, 'MemFree') + if (free === undefined) { + return undefined + } + const buffers = parseMeminfoKbValue(meminfo, 'Buffers') ?? 0 + const cached = parseMeminfoKbValue(meminfo, 'Cached') ?? 0 + const approxAvailable = free + buffers + cached + return Math.max(0, Math.min(100, Math.round(((total - approxAvailable) / total) * 100))) +} + +function computeMemoryPercent(): number | undefined { + if (platform() === 'linux') { + try { + const fromProc = readLinuxMemoryUsedPercent(readFileSync('/proc/meminfo', 'utf8')) + if (fromProc !== undefined) { + return fromProc + } + } catch { + // fall through to os.freemem() + } + } + + const total = totalmem() + if (total <= 0) { + return undefined + } + + const used = total - freemem() + return Math.max(0, Math.min(100, Math.round((used / total) * 100))) +} + +function isUnixLikeLoadPlatform(): boolean { + return platform() !== 'win32' +} + +function computeUptimeSeconds(): number | undefined { + const seconds = uptime() + if (!Number.isFinite(seconds) || seconds < 0) { + return undefined + } + return Math.floor(seconds) +} + +export function collectMachineHealth(now: number = Date.now()): MachineHealth { + const cpuCount = availableParallelism() + const memoryPercent = computeMemoryPercent() + const uptimeSeconds = computeUptimeSeconds() + const load1m = isUnixLikeLoadPlatform() ? loadavg()[0] : undefined + + const cpuSnapshot = sumCpuTimes() + let cpuPercent: number | undefined + if (cpuSnapshot && previousCpuSnapshot) { + cpuPercent = computeCpuPercent(cpuSnapshot, previousCpuSnapshot) + } + if (cpuSnapshot) { + previousCpuSnapshot = cpuSnapshot + } + + const health = { + collectedAt: now, + cpuCount, + ...(load1m !== undefined ? { load1m } : {}), + ...(cpuPercent !== undefined ? { cpuPercent } : {}), + ...(memoryPercent !== undefined ? { memoryPercent } : {}), + ...(uptimeSeconds !== undefined ? { uptimeSeconds } : {}) + } + + const parsed = MachineHealthSchema.safeParse(health) + if (!parsed.success) { + return { collectedAt: now } + } + return parsed.data +} + +/** Test helper */ +export function resetMachineHealthSamplerForTests(): void { + previousCpuSnapshot = null +} diff --git a/hub/src/socket/handlers/cli/machineHandlers.ts b/hub/src/socket/handlers/cli/machineHandlers.ts index 43c0555f..4c98d40e 100644 --- a/hub/src/socket/handlers/cli/machineHandlers.ts +++ b/hub/src/socket/handlers/cli/machineHandlers.ts @@ -9,6 +9,7 @@ import type { AccessErrorReason, AccessResult } from './types' type MachineAlivePayload = { machineId: string time: number + health?: unknown } type ResolveMachineAccess = (machineId: string) => AccessResult diff --git a/hub/src/socket/server.ts b/hub/src/socket/server.ts index 02728afa..e2228035 100644 --- a/hub/src/socket/server.ts +++ b/hub/src/socket/server.ts @@ -40,7 +40,7 @@ export type SocketServerDeps = { onSessionAlive?: (payload: { sid: string; time: number; thinking?: boolean; mode?: 'local' | 'remote' }) => void onSessionReady?: (payload: { sid: string; time: number }) => void onSessionEnd?: (payload: { sid: string; time: number }) => void - onMachineAlive?: (payload: { machineId: string; time: number }) => void + onMachineAlive?: (payload: { machineId: string; time: number; health?: unknown }) => void onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void onSessionActivity?: (sessionId: string, updatedAt: number) => void onSweepImmediateQueued?: (sessionId: string, now: number) => void diff --git a/hub/src/sync/aliveEvents.test.ts b/hub/src/sync/aliveEvents.test.ts index a218f493..abc02076 100644 --- a/hub/src/sync/aliveEvents.test.ts +++ b/hub/src/sync/aliveEvents.test.ts @@ -64,6 +64,55 @@ describe('alive incremental events', () => { expect(update.data).toEqual(expect.objectContaining({ id: machine.id, active: true })) }) + it('stores health from machine alive and rebroadcasts when it changes', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new MachineCache(store, createPublisher(events)) + + const machine = cache.getOrCreateMachine( + 'machine-health-test', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + + events.length = 0 + cache.handleMachineAlive({ + machineId: machine.id, + time: Date.now(), + health: { + collectedAt: Date.now(), + load1m: 0.4, + cpuCount: 8, + memoryPercent: 55 + } + }) + + const updated = cache.getMachine(machine.id) + expect(updated?.health).toEqual(expect.objectContaining({ load1m: 0.4, cpuCount: 8 })) + + events.length = 0 + cache.handleMachineAlive({ + machineId: machine.id, + time: Date.now() + 1, + health: { + collectedAt: Date.now() + 1, + load1m: 2.1, + cpuCount: 8, + memoryPercent: 80 + } + }) + + const healthUpdate = events.find((event) => event.type === 'machine-updated') + expect(healthUpdate).toBeDefined() + if (!healthUpdate || healthUpdate.type !== 'machine-updated' || !healthUpdate.data || typeof healthUpdate.data !== 'object') { + return + } + expect(healthUpdate.data).toEqual(expect.objectContaining({ + health: expect.objectContaining({ load1m: 2.1, memoryPercent: 80 }) + })) + }) + it('marks session thinking immediately when a user message is accepted by the hub', async () => { const store = new Store(':memory:') const emittedSocketUpdates: unknown[] = [] diff --git a/hub/src/sync/machineCache.ts b/hub/src/sync/machineCache.ts index 750db3e5..1579fd75 100644 --- a/hub/src/sync/machineCache.ts +++ b/hub/src/sync/machineCache.ts @@ -1,9 +1,38 @@ import type { Machine, MachinePatch } from '@hapi/protocol/types' -import { MachineMetadataSchema, RunnerStateSchema } from '@hapi/protocol/schemas' +import { MachineHealthSchema, MachineMetadataSchema, RunnerStateSchema } from '@hapi/protocol/schemas' import type { Store } from '../store' import { clampAliveTime } from './aliveTime' import { EventPublisher } from './eventPublisher' +type MachineAlivePayload = { + machineId: string + time: number + health?: unknown +} + +function parseMachineHealth(value: unknown): Machine['health'] { + const parsed = MachineHealthSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + +function healthDisplayChanged( + before: Machine['health'] | undefined, + after: Machine['health'] | null | undefined +): boolean { + if (!before && !after) { + return false + } + if (!before || !after) { + return true + } + + return before.load1m !== after.load1m + || before.cpuPercent !== after.cpuPercent + || before.memoryPercent !== after.memoryPercent + || before.cpuCount !== after.cpuCount + || before.uptimeSeconds !== after.uptimeSeconds +} + export class MachineCache { private readonly machines: Map = new Map() private readonly lastBroadcastAtByMachineId: Map = new Map() @@ -93,7 +122,8 @@ export class MachineCache { metadata, metadataVersion: stored.metadataVersion, runnerState, - runnerStateVersion: stored.runnerStateVersion + runnerStateVersion: stored.runnerStateVersion, + health: existing?.health ?? null } this.machines.set(machineId, machine) @@ -108,7 +138,7 @@ export class MachineCache { } } - handleMachineAlive(payload: { machineId: string; time: number }): void { + handleMachineAlive(payload: MachineAlivePayload): void { const t = clampAliveTime(payload.time) if (!t) return @@ -116,12 +146,21 @@ export class MachineCache { if (!machine) return const wasActive = machine.active + const previousHealth = machine.health ?? null machine.active = true machine.activeAt = Math.max(machine.activeAt, t) + if (payload.health !== undefined) { + machine.health = parseMachineHealth(payload.health) + } + const now = Date.now() const lastBroadcastAt = this.lastBroadcastAtByMachineId.get(machine.id) ?? 0 - const shouldBroadcast = (!wasActive && machine.active) || (now - lastBroadcastAt > 10_000) + const healthChanged = payload.health !== undefined + && healthDisplayChanged(previousHealth, machine.health) + const shouldBroadcast = (!wasActive && machine.active) + || healthChanged + || (now - lastBroadcastAt > 10_000) if (shouldBroadcast) { this.lastBroadcastAtByMachineId.set(machine.id, now) this.publisher.emit({ type: 'machine-updated', machineId: machine.id, data: machine }) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index d59f58fd..7986a81d 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -354,7 +354,7 @@ export class SyncEngine { this.sessionCache.recordSessionActivity(sessionId, updatedAt) } - handleMachineAlive(payload: { machineId: string; time: number }): void { + handleMachineAlive(payload: { machineId: string; time: number; health?: unknown }): void { this.machineCache.handleMachineAlive(payload) } diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 1b891fbe..58b1a8ae 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -278,6 +278,17 @@ export const RunnerStateSchema = z.object({ export type RunnerState = z.infer +export const MachineHealthSchema = z.object({ + collectedAt: z.number(), + cpuCount: z.number().int().positive().optional(), + load1m: z.number().nonnegative().optional(), + cpuPercent: z.number().min(0).max(100).optional(), + memoryPercent: z.number().min(0).max(100).optional(), + uptimeSeconds: z.number().nonnegative().optional() +}).strict() + +export type MachineHealth = z.infer + export const MachineSchema = z.object({ id: z.string(), namespace: z.string(), @@ -289,7 +300,8 @@ export const MachineSchema = z.object({ metadata: MachineMetadataSchema.nullable(), metadataVersion: z.number(), runnerState: RunnerStateSchema.nullable(), - runnerStateVersion: z.number() + runnerStateVersion: z.number(), + health: MachineHealthSchema.nullable().optional() }) export type Machine = z.infer diff --git a/shared/src/socket.ts b/shared/src/socket.ts index e050b0df..977dcd5c 100644 --- a/shared/src/socket.ts +++ b/shared/src/socket.ts @@ -219,7 +219,7 @@ export interface ClientToServerEvents { 'messages-consumed': (data: { sid: string; localIds: string[] }) => void 'update-metadata': (data: { sid: string; expectedVersion: number; metadata: unknown }, cb: (answer: UpdateMetadataAck) => void) => void 'update-state': (data: { sid: string; expectedVersion: number; agentState: unknown | null }, cb: (answer: UpdateStateAck) => void) => void - 'machine-alive': (data: { machineId: string; time: number }) => void + 'machine-alive': (data: { machineId: string; time: number; health?: unknown }) => void 'machine-update-metadata': (data: { machineId: string; expectedVersion: number; metadata: unknown }, cb: (answer: MachineUpdateMetadataAck) => void) => void 'machine-update-state': (data: { machineId: string; expectedVersion: number; runnerState: unknown | null }, cb: (answer: MachineUpdateStateAck) => void) => void 'rpc-register': (data: { method: string }) => void diff --git a/shared/src/types.ts b/shared/src/types.ts index 9c38fda2..63ffb71d 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -6,6 +6,7 @@ export type { DecryptedMessage, Metadata, Machine, + MachineHealth, MachineMetadata, MachinePatch, MachineUpdatedData, diff --git a/web/src/components/HoverTooltip.tsx b/web/src/components/HoverTooltip.tsx index cc523f06..d24744fe 100644 --- a/web/src/components/HoverTooltip.tsx +++ b/web/src/components/HoverTooltip.tsx @@ -5,6 +5,9 @@ import { cn } from '@/lib/utils' export const SESSION_ROW_TOOLTIP_FOCUS_CLASS = 'group-focus-visible/session-row:opacity-100 group-focus-visible/session-row:visible' +export const MACHINE_ROW_TOOLTIP_FOCUS_CLASS = + 'group-focus-visible/machine-row:opacity-100 group-focus-visible/machine-row:visible' + /** * Lightweight CSS-driven tooltip used by the session list to surface "why is * this indicator showing?" copy on hover/focus. Pure CSS reveal (no portal, @@ -30,21 +33,25 @@ export function HoverTooltip(props: { /** Rich tooltip content. Plain text or a small fragment with headings/lists. */ children: ReactNode side?: 'top' | 'bottom' - align?: 'start' | 'center' | 'end' + align?: 'start' | 'center' | 'end' | 'row' className?: string /** Parent-focus reveal classes (e.g. SESSION_ROW_TOOLTIP_FOCUS_CLASS). */ revealOnParentFocusClass?: string + /** Optional classes for the tooltip panel (e.g. wider popover). */ + tooltipClassName?: string }) { const side = props.side ?? 'bottom' const align = props.align ?? 'center' + const spansRow = align === 'row' - const alignClasses = - align === 'start' ? 'left-0' + const alignClasses = spansRow + ? 'left-1 right-1 w-auto' + : align === 'start' ? 'left-0' : align === 'end' ? 'right-0' : 'left-1/2 -translate-x-1/2' return ( - + {props.target} @@ -52,7 +59,8 @@ export function HoverTooltip(props: { role="tooltip" id={props.id} className={cn( - 'pointer-events-none absolute z-30 max-w-[14rem] whitespace-normal', + 'pointer-events-none absolute z-30 whitespace-normal', + spansRow ? 'max-w-none' : 'max-w-[14rem]', 'rounded-md border border-[var(--app-border)] bg-[var(--app-secondary-bg)]', 'px-2 py-1 text-xs leading-snug text-[var(--app-fg)] shadow-lg', side === 'top' ? 'bottom-full mb-1' : 'top-full mt-1', @@ -60,6 +68,7 @@ export function HoverTooltip(props: { 'opacity-0 invisible', 'group-hover:opacity-100 group-hover:visible', props.revealOnParentFocusClass, + props.tooltipClassName, 'transition-opacity duration-100' )} > diff --git a/web/src/components/MachineGroupHeader.test.tsx b/web/src/components/MachineGroupHeader.test.tsx new file mode 100644 index 00000000..1193cbeb --- /dev/null +++ b/web/src/components/MachineGroupHeader.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { Machine } from '@/types/api' +import { MachineGroupHeader } from './MachineGroupHeader' +import { I18nProvider } from '@/lib/i18n-context' + +const machine: Machine = { + id: 'Teemo', + namespace: 'default', + seq: 1, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + metadata: { + host: 'Teemo', + platform: 'win32', + happyCliVersion: '0.20.2', + }, + metadataVersion: 1, + runnerState: null, + runnerStateVersion: 0, +} + +describe('MachineGroupHeader', () => { + it('renders a single-row machine tile with os label and compact health', () => { + render( + + {}} + machine={machine} + healthPresentation={{ + metrics: [ + { id: 'cpu', shortLabel: 'CPU', percent: 12, tone: 'ok' }, + { id: 'ram', shortLabel: 'RAM', percent: 88, tone: 'warn' }, + ], + overallTone: 'warn', + status: 'elevated', + }} + /> + + ) + + expect(screen.getByRole('button', { name: /Teemo/i })).toBeTruthy() + expect(screen.getByText('Windows')).toBeTruthy() + expect(screen.getByText('(4)')).toBeTruthy() + expect(screen.getByLabelText(/CPU 12 percent; RAM 88 percent/i)).toBeTruthy() + }) + + it('shows compact uptime in the machine meta row', () => { + render( + + {}} + machine={{ + ...machine, + metadata: { ...machine.metadata!, host: 'proxmox', platform: 'linux' }, + }} + healthPresentation={{ + metrics: [ + { id: 'cpu', shortLabel: 'CPU', percent: 12, tone: 'ok' }, + { id: 'ram', shortLabel: 'RAM', percent: 40, tone: 'ok' }, + ], + overallTone: 'ok', + status: 'healthy', + uptimeDetail: '1h 54m', + }} + /> + + ) + + expect(screen.getByTitle('Linux · up 1h 54m')).toBeTruthy() + }) +}) diff --git a/web/src/components/MachineGroupHeader.tsx b/web/src/components/MachineGroupHeader.tsx new file mode 100644 index 00000000..6f8e5d5f --- /dev/null +++ b/web/src/components/MachineGroupHeader.tsx @@ -0,0 +1,133 @@ +import { useId } from 'react' +import type { Machine } from '@/types/api' +import { MACHINE_ROW_TOOLTIP_FOCUS_CLASS } from '@/components/HoverTooltip' +import { MachineHealthIndicator } from '@/components/MachineHealthIndicator' +import { + getMachineHost, + getMachinePlatform, + resolveMachineOsLabel, + shouldShowMachineHostSubtitle, + type MachineHealthPresentation, +} from '@/lib/machineHealth' +import { cn } from '@/lib/utils' +import { useTranslation } from '@/lib/use-translation' + +function MachineIcon(props: { className?: string }) { + return ( + + + + + + ) +} + +function ChevronIcon(props: { className?: string; collapsed?: boolean }) { + return ( + + + + ) +} + +function formatOsLabel( + osLabel: ReturnType, + t: (key: string) => string +): string { + if (osLabel.kind === 'raw') { + return osLabel.value + } + return t(osLabel.key) +} + +export function MachineGroupHeader(props: { + label: string + sessionCount: number + collapsed: boolean + onToggle: () => void + machine?: Machine + healthPresentation: MachineHealthPresentation | null +}) { + const { t } = useTranslation() + const healthTooltipId = useId() + const platform = getMachinePlatform(props.machine) + const host = getMachineHost(props.machine) + const osLabel = resolveMachineOsLabel(platform) + const osText = formatOsLabel(osLabel, t) + const showHost = shouldShowMachineHostSubtitle(props.label, host) + const uptimeText = props.healthPresentation?.uptimeDetail + const metaParts = [osText] + if (showHost && host) { + metaParts.push(host) + } + if (uptimeText) { + metaParts.push(t('machine.health.uptimeCompact', { value: uptimeText })) + } + const machineMeta = metaParts.join(' · ') + const hasHealth = props.healthPresentation && props.healthPresentation.metrics.length > 0 + + return ( + + ) +} diff --git a/web/src/components/MachineHealthIndicator.test.tsx b/web/src/components/MachineHealthIndicator.test.tsx new file mode 100644 index 00000000..677846f4 --- /dev/null +++ b/web/src/components/MachineHealthIndicator.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { MachineHealthIndicator } from './MachineHealthIndicator' +import { I18nProvider } from '@/lib/i18n-context' + +describe('MachineHealthIndicator', () => { + it('renders labeled cpu and ram meter bars', () => { + render( + + + + ) + + expect(screen.getByText('CPU')).toBeTruthy() + expect(screen.getByText('RAM')).toBeTruthy() + expect(screen.getByText('CPU across all 6 cores')).toBeTruthy() + expect(screen.getByLabelText(/CPU 72/i)).toBeTruthy() + }) + + it('renders inline percent labels', () => { + render( + + + + ) + + expect(screen.getByLabelText(/CPU 34 percent; RAM 56 percent/i)).toBeTruthy() + }) +}) diff --git a/web/src/components/MachineHealthIndicator.tsx b/web/src/components/MachineHealthIndicator.tsx new file mode 100644 index 00000000..457039d4 --- /dev/null +++ b/web/src/components/MachineHealthIndicator.tsx @@ -0,0 +1,181 @@ +import { useId } from 'react' +import { HoverTooltip } from '@/components/HoverTooltip' +import { + MACHINE_HEALTH_BAR_FILL_CLASS, + MACHINE_HEALTH_CHIP_CLASS, + getCpuMetricTooltipLabel, + type MachineHealthMetricPresentation, + type MachineHealthPresentation +} from '@/lib/machineHealth' +import { cn } from '@/lib/utils' +import { useTranslation } from '@/lib/use-translation' + +function HealthMeterBar(props: { + label: string + percent: number + tone: MachineHealthPresentation['overallTone'] + layout: 'stack' | 'inline' + compact?: boolean +}) { + const barWidthClass = props.compact ? 'w-8' : props.layout === 'inline' ? 'w-14' : 'w-11' + const labelWidthClass = props.compact ? 'w-5 text-[8px]' : 'w-6 text-[9px]' + + return ( +
+ + {props.label} + + + ) +} + +function TooltipMetricStat(props: { + metric: MachineHealthMetricPresentation + label: string +}) { + return ( + + {props.label} + + {props.metric.percent}% + + + ) +} + +function MachineHealthTooltipBody(props: { + presentation: MachineHealthPresentation +}) { + const { t } = useTranslation() + const { presentation } = props + const statusKey = `machine.health.status.${presentation.status}` as const + + return ( + + + {t('machine.health.tooltip.title')} + {t(statusKey)} + + + {presentation.metrics.map((metric) => ( + + ))} + {presentation.loadDetail ? ( + + {t('machine.health.tooltip.loadShort')} + + {presentation.loadDetail} + + + ) : null} + {presentation.uptimeDetail ? ( + + {t('machine.health.tooltip.uptimeShort')} + + {presentation.uptimeDetail} + + + ) : null} + + + {t('machine.health.tooltip.hint')} + + + ) +} + +export function MachineHealthIndicator(props: { + presentation: MachineHealthPresentation + className?: string + layout?: 'stack' | 'inline' + compact?: boolean + tooltipId?: string + revealOnParentFocusClass?: string +}) { + const { t } = useTranslation() + const generatedTooltipId = useId() + const tooltipId = props.tooltipId ?? generatedTooltipId + const { presentation, layout = 'stack', compact = false } = props + + const ariaLabel = presentation.metrics.length > 0 + ? presentation.metrics + .map((metric) => t(`machine.health.aria.${metric.id}`, { n: metric.percent })) + .join('; ') + : t('machine.health.aria.unknown') + + const chip = ( + + {presentation.metrics.map((metric) => ( + + ))} + + ) + + return ( + + + + ) +} diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 8443b882..4b90e8be 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -22,6 +22,9 @@ import { formatRelativeTime } from '@/lib/relativeTime' import { formatScheduledTooltipDetail } from '@/lib/scheduledTime' import { getCodexImportedAt, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions' import { formatReopenError } from '@/lib/reopenError' +import type { Machine } from '@/types/api' +import { getMachinePlatform, presentMachineHealth } from '@/lib/machineHealth' +import { MachineGroupHeader } from '@/components/MachineGroupHeader' type SessionGroup = { key: string @@ -542,28 +545,6 @@ function SessionListSearch(props: { ) } -function MachineIcon(props: { className?: string }) { - return ( - - - - - - ) -} - - function formatCodexImportedRelativeTime(value: number, t: (key: string, params?: Record) => string): string | null { const ms = value < 1_000_000_000_000 ? value * 1000 : value if (!Number.isFinite(ms)) return null @@ -802,10 +783,11 @@ export function SessionList(props: { renderHeader?: boolean api: ApiClient | null machineLabelsById?: Record + machinesById?: Record selectedSessionId?: string | null }) { const { t } = useTranslation() - const { renderHeader = true, api, selectedSessionId, machineLabelsById = {}, onNewSessionInDirectory } = props + const { renderHeader = true, api, selectedSessionId, machineLabelsById = {}, machinesById = {}, onNewSessionInDirectory } = props const { sessionPreviewLimit } = useSessionPreviewLimit() const { sessionListStatusMode } = useSessionListStatusMode() const { showActiveSessionsOnly } = useShowActiveSessionsOnly() @@ -1045,19 +1027,21 @@ export function SessionList(props: {
{machineGroups.map((mg) => { const machineCollapsed = isMachineCollapsed(mg) + const machine = mg.machineId ? machinesById[mg.machineId] : undefined + const healthPresentation = presentMachineHealth( + machine?.health, + getMachinePlatform(machine) + ) return (
- {/* Level 1: Machine */} - + toggleMachine(mg)} + machine={machine} + healthPresentation={healthPresentation} + /> {/* Level 2: Projects */}
diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index dcaeeb20..3e1d9220 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -245,6 +245,27 @@ export default { // Machine 'machine.unknown': 'Unknown platform', + 'machine.os.windows': 'Windows', + 'machine.os.linux': 'Linux', + 'machine.os.macos': 'macOS', + 'machine.os.unknown': 'Unknown OS', + 'machine.header.sessionCount': '{n} sessions', + 'machine.health.tooltip.title': 'Machine capacity', + 'machine.health.status.healthy': 'Healthy — room for more agents', + 'machine.health.status.elevated': 'Elevated — new agents may run slower', + 'machine.health.status.high': 'High pressure — avoid spawning more here', + 'machine.health.status.unknown': 'Metrics unavailable', + 'machine.health.metric.cpu': 'CPU across all cores', + 'machine.health.metric.cpuWithCount': 'CPU across all {n} cores', + 'machine.health.metric.ram': 'RAM in use', + 'machine.health.tooltip.load': 'Run queue (1 min): {value}', + 'machine.health.tooltip.loadShort': 'Load (1m)', + 'machine.health.tooltip.uptimeShort': 'Uptime', + 'machine.health.uptimeCompact': 'up {value}', + 'machine.health.tooltip.hint': 'Updated every ~20s from the runner on this machine.', + 'machine.health.aria.cpu': 'CPU {n} percent', + 'machine.health.aria.ram': 'RAM {n} percent', + 'machine.health.aria.unknown': 'Machine health unavailable', // Chat 'chat.placeholder': 'Type a message…', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 7508af10..7518bb77 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -249,6 +249,27 @@ export default { // Machine 'machine.unknown': '未知平台', + 'machine.os.windows': 'Windows', + 'machine.os.linux': 'Linux', + 'machine.os.macos': 'macOS', + 'machine.os.unknown': '未知系统', + 'machine.header.sessionCount': '{n} 个会话', + 'machine.health.tooltip.title': '机器负载', + 'machine.health.status.healthy': '健康 — 还可运行更多代理', + 'machine.health.status.elevated': '偏高 — 新代理可能变慢', + 'machine.health.status.high': '高压 — 避免在此继续启动', + 'machine.health.status.unknown': '指标不可用', + 'machine.health.metric.cpu': '全部核心的 CPU', + 'machine.health.metric.cpuWithCount': '全部 {n} 个核心的 CPU', + 'machine.health.metric.ram': '内存占用', + 'machine.health.tooltip.load': '运行队列 (1 分钟): {value}', + 'machine.health.tooltip.loadShort': '负载 (1 分钟)', + 'machine.health.tooltip.uptimeShort': '运行时间', + 'machine.health.uptimeCompact': '已运行 {value}', + 'machine.health.tooltip.hint': '约每 20 秒由该机器上的 runner 更新。', + 'machine.health.aria.cpu': 'CPU {n}%', + 'machine.health.aria.ram': '内存 {n}%', + 'machine.health.aria.unknown': '机器健康数据不可用', // Chat 'chat.placeholder': '输入消息…', diff --git a/web/src/lib/machineHealth.test.ts b/web/src/lib/machineHealth.test.ts new file mode 100644 index 00000000..9770d61a --- /dev/null +++ b/web/src/lib/machineHealth.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { + formatMachineUptimeSeconds, + presentMachineHealth, + resolveMachineOsLabel, + shouldShowMachineHostSubtitle, + getCpuMetricTooltipLabel, +} from './machineHealth' + +describe('presentMachineHealth', () => { + it('builds cpu and ram metrics for visual meters', () => { + const result = presentMachineHealth({ + collectedAt: Date.now(), + load1m: 2.4, + cpuCount: 8, + cpuPercent: 72, + memoryPercent: 81 + }, 'linux') + + expect(result?.metrics).toEqual([ + { id: 'cpu', shortLabel: 'CPU', percent: 72, tone: 'ok' }, + { id: 'ram', shortLabel: 'RAM', percent: 81, tone: 'warn' } + ]) + expect(result?.overallTone).toBe('warn') + expect(result?.status).toBe('elevated') + expect(result?.loadDetail).toBe('2.4/8') + expect(result?.cpuCount).toBe(8) + }) + + it('marks high pressure when ram is critical', () => { + const result = presentMachineHealth({ + collectedAt: Date.now(), + cpuPercent: 42, + memoryPercent: 93 + }, 'linux') + + expect(result?.overallTone).toBe('critical') + expect(result?.status).toBe('high') + }) + + it('returns null when health is missing', () => { + expect(presentMachineHealth(null, 'linux')).toBeNull() + }) + + it('formats uptime for tooltip and tile meta', () => { + const result = presentMachineHealth({ + collectedAt: Date.now(), + cpuPercent: 10, + memoryPercent: 20, + uptimeSeconds: 6_540, + }, 'linux') + + expect(result?.uptimeDetail).toBe('1h 49m') + }) +}) + +describe('formatMachineUptimeSeconds', () => { + it('formats days, hours, minutes, and seconds', () => { + expect(formatMachineUptimeSeconds(90_000)).toBe('1d 1h') + expect(formatMachineUptimeSeconds(3_720)).toBe('1h 2m') + expect(formatMachineUptimeSeconds(300)).toBe('5m') + expect(formatMachineUptimeSeconds(45)).toBe('45s') + }) +}) + +describe('resolveMachineOsLabel', () => { + it('maps known platforms to i18n keys', () => { + expect(resolveMachineOsLabel('win32')).toEqual({ kind: 'i18n', key: 'machine.os.windows' }) + expect(resolveMachineOsLabel('linux')).toEqual({ kind: 'i18n', key: 'machine.os.linux' }) + expect(resolveMachineOsLabel('darwin')).toEqual({ kind: 'i18n', key: 'machine.os.macos' }) + }) + + it('falls back to raw platform string when unknown', () => { + expect(resolveMachineOsLabel('freebsd')).toEqual({ kind: 'raw', value: 'freebsd' }) + }) +}) + +describe('getCpuMetricTooltipLabel', () => { + const t = (key: string, params?: Record) => { + if (key === 'machine.health.metric.cpuWithCount') { + return `CPU across all ${params?.n} cores` + } + return 'CPU across all cores' + } + + it('includes core count when known', () => { + expect(getCpuMetricTooltipLabel(6, t)).toBe('CPU across all 6 cores') + }) + + it('falls back when core count is missing', () => { + expect(getCpuMetricTooltipLabel(undefined, t)).toBe('CPU across all cores') + }) +}) +describe('shouldShowMachineHostSubtitle', () => { + it('hides host when it matches the display label', () => { + expect(shouldShowMachineHostSubtitle('Teemo', 'Teemo')).toBe(false) + }) + + it('shows host when it differs from the display label', () => { + expect(shouldShowMachineHostSubtitle('f9bb3c9e', 'proxmox')).toBe(true) + }) +}) diff --git a/web/src/lib/machineHealth.ts b/web/src/lib/machineHealth.ts new file mode 100644 index 00000000..24b074a7 --- /dev/null +++ b/web/src/lib/machineHealth.ts @@ -0,0 +1,198 @@ +import type { Machine, MachineHealth } from '@/types/api' + +export type MachineHealthTone = 'ok' | 'warn' | 'critical' | 'unknown' + +export type MachineHealthMetricPresentation = { + id: 'cpu' | 'ram' + shortLabel: 'CPU' | 'RAM' + percent: number + tone: MachineHealthTone +} + +export type MachineHealthPresentation = { + metrics: MachineHealthMetricPresentation[] + overallTone: MachineHealthTone + loadDetail?: string + uptimeDetail?: string + cpuCount?: number + status: 'healthy' | 'elevated' | 'high' | 'unknown' +} + +/** Compact uptime for sidebar tiles, e.g. 1h 54m, 2d 4h, 5m. */ +export function formatMachineUptimeSeconds(totalSeconds: number): string | null { + if (!Number.isFinite(totalSeconds) || totalSeconds < 0) { + return null + } + + const seconds = Math.floor(totalSeconds) + const days = Math.floor(seconds / 86_400) + const hours = Math.floor((seconds % 86_400) / 3_600) + const minutes = Math.floor((seconds % 3_600) / 60) + + if (days > 0) { + return `${days}d ${hours}h` + } + if (hours > 0) { + return `${hours}h ${minutes}m` + } + if (minutes > 0) { + return `${minutes}m` + } + return `${seconds}s` +} + +function formatLoad(load1m: number, cpuCount?: number): string { + if (cpuCount && cpuCount > 0) { + return `${load1m.toFixed(1)}/${cpuCount}` + } + return load1m.toFixed(1) +} + +function loadTone(load1m: number, cpuCount?: number): MachineHealthTone { + const cores = cpuCount && cpuCount > 0 ? cpuCount : 1 + const ratio = load1m / cores + if (ratio >= 1.5) return 'critical' + if (ratio >= 1) return 'warn' + return 'ok' +} + +function percentTone(value: number): MachineHealthTone { + if (value >= 90) return 'critical' + if (value >= 75) return 'warn' + return 'ok' +} + +function worstTone(...tones: MachineHealthTone[]): MachineHealthTone { + if (tones.includes('critical')) return 'critical' + if (tones.includes('warn')) return 'warn' + if (tones.includes('unknown')) return 'unknown' + return 'ok' +} + +function statusFromTone(tone: MachineHealthTone): MachineHealthPresentation['status'] { + if (tone === 'critical') return 'high' + if (tone === 'warn') return 'elevated' + if (tone === 'ok') return 'healthy' + return 'unknown' +} + +export function presentMachineHealth( + health: MachineHealth | null | undefined, + platform?: string | null +): MachineHealthPresentation | null { + if (!health) { + return null + } + + const metrics: MachineHealthMetricPresentation[] = [] + const tones: MachineHealthTone[] = [] + + if (health.cpuPercent !== undefined) { + const tone = percentTone(health.cpuPercent) + metrics.push({ + id: 'cpu', + shortLabel: 'CPU', + percent: health.cpuPercent, + tone + }) + tones.push(tone) + } + + if (health.memoryPercent !== undefined) { + const tone = percentTone(health.memoryPercent) + metrics.push({ + id: 'ram', + shortLabel: 'RAM', + percent: health.memoryPercent, + tone + }) + tones.push(tone) + } + + const loadDetail = health.load1m !== undefined && platform !== 'win32' + ? formatLoad(health.load1m, health.cpuCount) + : undefined + const uptimeDetail = health.uptimeSeconds !== undefined + ? formatMachineUptimeSeconds(health.uptimeSeconds) + : undefined + + if (loadDetail !== undefined) { + tones.push(loadTone(health.load1m!, health.cpuCount)) + } + + if (metrics.length === 0 && loadDetail === undefined && uptimeDetail === undefined) { + return { + metrics: [], + overallTone: 'unknown', + status: 'unknown' + } + } + + const overallTone = metrics.length > 0 ? worstTone(...tones) : loadTone(health.load1m!, health.cpuCount) + + return { + metrics, + overallTone, + loadDetail, + uptimeDetail: uptimeDetail ?? undefined, + cpuCount: health.cpuCount, + status: statusFromTone(overallTone) + } +} + +export function getCpuMetricTooltipLabel( + cpuCount: number | undefined, + t: (key: string, params?: Record) => string +): string { + if (cpuCount !== undefined && cpuCount > 0) { + return t('machine.health.metric.cpuWithCount', { n: cpuCount }) + } + return t('machine.health.metric.cpu') +} + +export function getMachinePlatform(machine: Machine | null | undefined): string | null { + return machine?.metadata?.platform ?? null +} + +export function getMachineHost(machine: Machine | null | undefined): string | null { + return machine?.metadata?.host ?? null +} + +export type MachineOsLabel = + | { kind: 'i18n'; key: 'machine.os.windows' | 'machine.os.linux' | 'machine.os.macos' | 'machine.os.unknown' } + | { kind: 'raw'; value: string } + +export function resolveMachineOsLabel(platform: string | null | undefined): MachineOsLabel { + switch (platform) { + case 'win32': + return { kind: 'i18n', key: 'machine.os.windows' } + case 'linux': + return { kind: 'i18n', key: 'machine.os.linux' } + case 'darwin': + return { kind: 'i18n', key: 'machine.os.macos' } + default: + if (platform?.trim()) { + return { kind: 'raw', value: platform.trim() } + } + return { kind: 'i18n', key: 'machine.os.unknown' } + } +} + +export function shouldShowMachineHostSubtitle(label: string, host: string | null | undefined): boolean { + if (!host?.trim()) return false + return host.trim().toLowerCase() !== label.trim().toLowerCase() +} + +export const MACHINE_HEALTH_BAR_FILL_CLASS: Record = { + ok: 'bg-[var(--app-link)]/70', + warn: 'bg-[var(--app-badge-warning-text)]', + critical: 'bg-[var(--app-badge-error-text)]', + unknown: 'bg-[var(--app-hint)]/50' +} + +export const MACHINE_HEALTH_CHIP_CLASS: Record = { + ok: 'border-[var(--app-border)] bg-[var(--app-subtle-bg)]/80', + warn: 'border-[var(--app-badge-warning-border)] bg-[var(--app-badge-warning-bg)]/40', + critical: 'border-[var(--app-badge-error-border)] bg-[var(--app-badge-error-bg)]/40', + unknown: 'border-[var(--app-border)] bg-[var(--app-subtle-bg)]/60 opacity-70' +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 84222fef..ec6ea6f6 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -190,6 +190,13 @@ function SessionsPage() { } return labels }, [machines]) + const machinesById = useMemo(() => { + const byId: Record = {} + for (const machine of machines) { + byId[machine.id] = machine + } + return byId + }, [machines]) const sessionMatch = matchRoute({ to: '/sessions/$sessionId', fuzzy: true }) const selectedSessionId = sessionMatch && sessionMatch.sessionId !== 'new' ? sessionMatch.sessionId : null const selectedSession = useMemo( @@ -531,6 +538,7 @@ function SessionsPage() { renderHeader={false} api={api} machineLabelsById={machineLabelsById} + machinesById={machinesById} />
diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 89846a75..bee38fcf 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -46,6 +46,7 @@ export type { Metadata, PermissionMode, Machine, + MachineHealth, PendingRequest, PendingRequestKind, RunnerState,