mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
* 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>
189 lines
5.8 KiB
TypeScript
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'
|
|
}
|