Files
hapi/web/src/lib/machineHealth.ts
T
77f94ef738 fix(web): keep machine names visible and health tooltips touchable (#1049)
* fix(web): improve machine health sidebar UX

Keep machine names visible, align health metrics, and make nested health tooltips usable with touch and keyboard input.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): preserve tooltip focus reveal groups

Keep the unnamed group used by existing focus reveal classes while retaining named hover groups for nested machine health tooltips.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): add exec* timestamps to ToolCard test fixture

Unblocks typecheck after #1036 made execStartedAt/execCompletedAt
required on ChatToolCall; fixture was missing both fields.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): align machine health status with meters

Right-align the capacity status with the utilization meter edge to balance the tooltip header without shortening the bars.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): restore machine health disclosure semantics

Expose the machine group's expanded state on its toggle and describe the health trigger with the tooltip body for assistive technologies.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 12:24:09 +08:00

189 lines
5.8 KiB
TypeScript

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 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<MachineHealthTone, string> = {
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<MachineHealthTone, string> = {
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'
}