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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-06-29 11:41:53 +08:00
committed by GitHub
co-authored by Cursor
parent 5ade952218
commit 26a24bb6ce
24 changed files with 1194 additions and 50 deletions
+14 -5
View File
@@ -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 (
<span className={cn('relative inline-flex group', props.className)}>
<span className={cn(spansRow ? 'static' : 'relative', 'inline-flex group', props.className)}>
<span className="inline-flex">
{props.target}
</span>
@@ -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'
)}
>
@@ -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(
<I18nProvider>
<MachineGroupHeader
label="Teemo"
sessionCount={4}
collapsed={false}
onToggle={() => {}}
machine={machine}
healthPresentation={{
metrics: [
{ id: 'cpu', shortLabel: 'CPU', percent: 12, tone: 'ok' },
{ id: 'ram', shortLabel: 'RAM', percent: 88, tone: 'warn' },
],
overallTone: 'warn',
status: 'elevated',
}}
/>
</I18nProvider>
)
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(
<I18nProvider>
<MachineGroupHeader
label="proxmox"
sessionCount={2}
collapsed={false}
onToggle={() => {}}
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',
}}
/>
</I18nProvider>
)
expect(screen.getByTitle('Linux · up 1h 54m')).toBeTruthy()
})
})
+133
View File
@@ -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 (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
)
}
function ChevronIcon(props: { className?: string; collapsed?: boolean }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
props.className,
'transition-transform duration-200',
props.collapsed ? '' : 'rotate-90'
)}
>
<polyline points="9 18 15 12 9 6" />
</svg>
)
}
function formatOsLabel(
osLabel: ReturnType<typeof resolveMachineOsLabel>,
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 (
<button
type="button"
onClick={props.onToggle}
aria-describedby={hasHealth ? healthTooltipId : undefined}
className={cn(
'group/machine-row relative flex w-full min-w-0 items-center gap-2 px-1 py-1.5 text-left rounded-lg select-none',
'border border-[var(--app-border)] bg-[var(--app-subtle-bg)]/70',
'transition-colors hover:bg-[var(--app-subtle-bg)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]'
)}
>
<ChevronIcon className="h-4 w-4 shrink-0 text-[var(--app-hint)]" collapsed={props.collapsed} />
<MachineIcon className="h-4 w-4 shrink-0 text-[var(--app-link)]/80" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--app-fg)]">
{props.label}
</span>
<span
className="min-w-0 max-w-[8rem] shrink truncate text-[11px] text-[var(--app-hint)]"
title={machineMeta}
>
{machineMeta}
</span>
{hasHealth ? (
<MachineHealthIndicator
presentation={props.healthPresentation!}
layout="inline"
compact
className="shrink-0"
tooltipId={healthTooltipId}
revealOnParentFocusClass={MACHINE_ROW_TOOLTIP_FOCUS_CLASS}
/>
) : null}
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-[var(--app-hint)]">
({props.sessionCount})
</span>
</button>
)
}
@@ -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(
<I18nProvider>
<MachineHealthIndicator
presentation={{
metrics: [
{ id: 'cpu', shortLabel: 'CPU', percent: 72, tone: 'ok' },
{ id: 'ram', shortLabel: 'RAM', percent: 81, tone: 'warn' }
],
overallTone: 'warn',
status: 'elevated',
loadDetail: '2.4/8',
cpuCount: 6,
}}
/>
</I18nProvider>
)
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(
<I18nProvider>
<MachineHealthIndicator
layout="inline"
presentation={{
metrics: [
{ id: 'cpu', shortLabel: 'CPU', percent: 34, tone: 'ok' },
{ id: 'ram', shortLabel: 'RAM', percent: 56, tone: 'warn' }
],
overallTone: 'warn',
status: 'elevated',
}}
/>
</I18nProvider>
)
expect(screen.getByLabelText(/CPU 34 percent; RAM 56 percent/i)).toBeTruthy()
})
})
@@ -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 (
<div className="flex items-center gap-0.5 min-w-0">
<span className={cn('shrink-0 font-semibold uppercase tracking-wide text-[var(--app-hint)]', labelWidthClass)}>
{props.label}
</span>
<div
className={cn(
'relative h-1.5 shrink-0 overflow-hidden rounded-full bg-[var(--app-border)]/80',
barWidthClass
)}
aria-hidden="true"
>
<div
className={cn('h-full rounded-full transition-[width]', MACHINE_HEALTH_BAR_FILL_CLASS[props.tone])}
style={{ width: `${Math.max(4, Math.min(100, props.percent))}%` }}
/>
</div>
{props.layout === 'inline' && !props.compact ? (
<span className="w-7 shrink-0 text-[10px] tabular-nums text-[var(--app-fg)]/80">
{props.percent}%
</span>
) : null}
</div>
)
}
function TooltipMetricStat(props: {
metric: MachineHealthMetricPresentation
label: string
}) {
return (
<span className="inline-flex min-w-[7.5rem] items-center gap-2 whitespace-nowrap">
<span className="text-[var(--app-hint)]">{props.label}</span>
<span
className={cn(
'font-semibold tabular-nums',
props.metric.tone !== 'ok' ? 'text-[var(--app-fg)]' : 'text-[var(--app-fg)]/90'
)}
>
{props.metric.percent}%
</span>
<span
className="relative h-1.5 w-14 overflow-hidden rounded-full bg-[var(--app-border)]/80"
aria-hidden="true"
>
<span
className={cn('block h-full rounded-full', MACHINE_HEALTH_BAR_FILL_CLASS[props.metric.tone])}
style={{ width: `${Math.max(4, Math.min(100, props.metric.percent))}%` }}
/>
</span>
</span>
)
}
function MachineHealthTooltipBody(props: {
presentation: MachineHealthPresentation
}) {
const { t } = useTranslation()
const { presentation } = props
const statusKey = `machine.health.status.${presentation.status}` as const
return (
<span className="block space-y-1.5">
<span className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5">
<span className="font-medium">{t('machine.health.tooltip.title')}</span>
<span className="text-[var(--app-fg)]">{t(statusKey)}</span>
</span>
<span className="flex flex-wrap items-center gap-x-5 gap-y-1">
{presentation.metrics.map((metric) => (
<TooltipMetricStat
key={metric.id}
metric={metric}
label={metric.id === 'cpu'
? getCpuMetricTooltipLabel(presentation.cpuCount, t)
: t(`machine.health.metric.${metric.id}`, { n: metric.percent })}
/>
))}
{presentation.loadDetail ? (
<span className="inline-flex min-w-[7.5rem] items-center gap-2 whitespace-nowrap text-[var(--app-hint)]">
<span>{t('machine.health.tooltip.loadShort')}</span>
<span className="font-semibold tabular-nums text-[var(--app-fg)]">
{presentation.loadDetail}
</span>
</span>
) : null}
{presentation.uptimeDetail ? (
<span className="inline-flex min-w-[7.5rem] items-center gap-2 whitespace-nowrap text-[var(--app-hint)]">
<span>{t('machine.health.tooltip.uptimeShort')}</span>
<span className="font-semibold tabular-nums text-[var(--app-fg)]">
{presentation.uptimeDetail}
</span>
</span>
) : null}
</span>
<span className="block text-[11px] leading-snug text-[var(--app-hint)]">
{t('machine.health.tooltip.hint')}
</span>
</span>
)
}
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 = (
<span
className={cn(
'inline-flex rounded-md border',
compact ? 'flex-row flex-nowrap items-center gap-x-1.5 px-1 py-0.5' : layout === 'inline'
? 'flex-row flex-wrap items-center gap-x-3 gap-y-1 px-1.5 py-1'
: 'flex-col gap-0.5 px-1.5 py-1',
MACHINE_HEALTH_CHIP_CLASS[presentation.overallTone],
props.className
)}
aria-label={ariaLabel}
>
{presentation.metrics.map((metric) => (
<HealthMeterBar
key={metric.id}
label={metric.shortLabel}
percent={metric.percent}
tone={metric.tone}
layout={layout}
compact={compact}
/>
))}
</span>
)
return (
<HoverTooltip
id={tooltipId}
target={chip}
side="bottom"
align="end"
className="shrink-0"
tooltipClassName="px-3 py-2 min-w-[16rem]"
revealOnParentFocusClass={props.revealOnParentFocusClass}
>
<MachineHealthTooltipBody presentation={presentation} />
</HoverTooltip>
)
}
+18 -34
View File
@@ -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 (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
)
}
function formatCodexImportedRelativeTime(value: number, t: (key: string, params?: Record<string, string | number>) => 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<string, string>
machinesById?: Record<string, Machine>
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: {
<div className="flex flex-col gap-3 px-2 pt-1 pb-2">
{machineGroups.map((mg) => {
const machineCollapsed = isMachineCollapsed(mg)
const machine = mg.machineId ? machinesById[mg.machineId] : undefined
const healthPresentation = presentMachineHealth(
machine?.health,
getMachinePlatform(machine)
)
return (
<div key={mg.machineId ?? UNKNOWN_MACHINE_ID}>
{/* Level 1: Machine */}
<button
type="button"
onClick={() => toggleMachine(mg)}
className="flex w-full items-center gap-2 px-1 py-1.5 text-left rounded-lg transition-colors hover:bg-[var(--app-subtle-bg)] select-none"
>
<ChevronIcon className="h-4 w-4 text-[var(--app-hint)] shrink-0" collapsed={machineCollapsed} />
<MachineIcon className="h-4 w-4 text-[var(--app-hint)] shrink-0" />
<span className="text-sm font-semibold truncate flex-1">{mg.label}</span>
<span className="text-[11px] tabular-nums text-[var(--app-hint)] shrink-0">({mg.totalSessions})</span>
</button>
<MachineGroupHeader
label={mg.label}
sessionCount={mg.totalSessions}
collapsed={machineCollapsed}
onToggle={() => toggleMachine(mg)}
machine={machine}
healthPresentation={healthPresentation}
/>
{/* Level 2: Projects */}
<div className="collapsible-panel" data-open={!machineCollapsed || undefined}>
+21
View File
@@ -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…',
+21
View File
@@ -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': '输入消息…',
+102
View File
@@ -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<string, string | number>) => {
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)
})
})
+198
View File
@@ -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 | number>) => 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<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'
}
+8
View File
@@ -190,6 +190,13 @@ function SessionsPage() {
}
return labels
}, [machines])
const machinesById = useMemo(() => {
const byId: Record<string, typeof machines[number]> = {}
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}
/>
</div>
</div>
+1
View File
@@ -46,6 +46,7 @@ export type {
Metadata,
PermissionMode,
Machine,
MachineHealth,
PendingRequest,
PendingRequestKind,
RunnerState,