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,