diff --git a/src/process-metrics.test.ts b/src/process-metrics.test.ts new file mode 100644 index 0000000..d4e41de --- /dev/null +++ b/src/process-metrics.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import { + ProcessTreeMetricsSampler, + formatBytes, + formatCountdown, + formatCpuPercent, + formatDuration, + parseProcStat, + type ProcessMetricsReader, + type ProcessStat, +} from "./process-metrics"; + +const reader = (stats: ProcessStat[][], times: number[]): ProcessMetricsReader => { + let sampleIndex = 0; + return { + now: () => times[Math.min(sampleIndex, times.length - 1)] ?? 0, + clockTicksPerSecond: 100, + pageSizeBytes: 1024, + cpuCount: 1, + listProcessIds: async () => + stats[Math.min(sampleIndex, stats.length - 1)]?.map((s) => s.pid) ?? [], + readProcessStat: async (pid) => { + const sample = stats[Math.min(sampleIndex, stats.length - 1)] ?? []; + const stat = sample.find((entry) => entry.pid === pid) ?? null; + if (pid === sample.at(-1)?.pid) sampleIndex += 1; + return stat; + }, + }; +}; + +describe("ProcessTreeMetricsSampler", () => { + test("samples process tree RSS and interval CPU", async () => { + const sampler = new ProcessTreeMetricsSampler( + reader( + [ + [ + { pid: 10, parentPid: 1, totalCpuTicks: 100, rssPages: 10 }, + { pid: 11, parentPid: 10, totalCpuTicks: 50, rssPages: 5 }, + { pid: 20, parentPid: 1, totalCpuTicks: 1000, rssPages: 100 }, + ], + [ + { pid: 10, parentPid: 1, totalCpuTicks: 130, rssPages: 11 }, + { pid: 11, parentPid: 10, totalCpuTicks: 70, rssPages: 6 }, + { pid: 20, parentPid: 1, totalCpuTicks: 2000, rssPages: 100 }, + ], + ], + [0, 1000, 2000], + ), + ); + + expect(await sampler.sample(10)).toMatchObject({ cpuPercent: null, rssBytes: 15 * 1024 }); + expect(await sampler.sample(10)).toMatchObject({ cpuPercent: 50, rssBytes: 17 * 1024 }); + }); + + test("resets baseline when pid changes", async () => { + const sampler = new ProcessTreeMetricsSampler( + reader( + [ + [{ pid: 10, parentPid: 1, totalCpuTicks: 100, rssPages: 10 }], + [{ pid: 20, parentPid: 1, totalCpuTicks: 500, rssPages: 20 }], + ], + [0, 1000], + ), + ); + + await sampler.sample(10); + + expect(await sampler.sample(20)).toMatchObject({ pid: 20, cpuPercent: null }); + }); + + test("calculates CPU deltas per surviving process when children exit", async () => { + const sampler = new ProcessTreeMetricsSampler( + reader( + [ + [ + { pid: 10, parentPid: 1, totalCpuTicks: 100, rssPages: 10 }, + { pid: 11, parentPid: 10, totalCpuTicks: 500, rssPages: 5 }, + ], + [{ pid: 10, parentPid: 1, totalCpuTicks: 130, rssPages: 11 }], + ], + [0, 1000, 2000], + ), + ); + + await sampler.sample(10); + + expect(await sampler.sample(10)).toMatchObject({ cpuPercent: 30 }); + }); +}); + +describe("process metric formatting", () => { + test("parses proc stat fields", () => { + expect( + parseProcStat("123 (node server) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22"), + ).toMatchObject({ pid: 123, parentPid: 1, totalCpuTicks: 23, rssPages: 21 }); + }); + + test("formats compact values", () => { + expect(formatCpuPercent(null)).toBe("—"); + expect(formatCpuPercent(12.345)).toBe("12.3%"); + expect(formatBytes(null)).toBe("—"); + expect(formatBytes(1536)).toBe("1.5KB"); + expect(formatDuration(null)).toBe("—"); + expect(formatDuration("2026-06-09T00:00:00.000Z", Date.parse("2026-06-09T00:01:12.000Z"))).toBe( + "1m 12s", + ); + expect(formatCountdown(850)).toBe("850ms"); + expect(formatCountdown(2400)).toBe("2.4s"); + expect(formatCountdown(72_000)).toBe("1m 12s"); + }); +}); diff --git a/src/process-metrics.ts b/src/process-metrics.ts new file mode 100644 index 0000000..5081b75 --- /dev/null +++ b/src/process-metrics.ts @@ -0,0 +1,216 @@ +import { readFile, readdir } from "node:fs/promises"; +import { cpus } from "node:os"; +import { join } from "node:path"; + +export interface ProcessMetricsSample { + pid: number; + cpuPercent: number | null; + rssBytes: number | null; + sampledAt: number; +} + +export interface ProcessMetricsReader { + now: () => number; + clockTicksPerSecond: number; + pageSizeBytes: number; + cpuCount: number; + listProcessIds: () => Promise; + readProcessStat: (pid: number) => Promise; +} + +export interface ProcessStat { + pid: number; + parentPid: number; + totalCpuTicks: number; + rssPages: number; +} + +interface ProcessMetricsSnapshot { + totalCpuTicks: number; + cpuTicksByPid: Map; + rssBytes: number; + sampledAt: number; +} + +const DEFAULT_CLOCK_TICKS_PER_SECOND = 100; +const DEFAULT_PAGE_SIZE_BYTES = 4096; + +export class ProcessTreeMetricsSampler { + private previous: ProcessMetricsSnapshot | null = null; + private previousPid: number | null = null; + + constructor(private readonly reader: ProcessMetricsReader = createProcfsMetricsReader()) {} + + reset(): void { + this.previous = null; + this.previousPid = null; + } + + async sample(pid: number | null): Promise { + if (pid === null) { + this.reset(); + return null; + } + + const snapshot = await this.snapshot(pid); + if (!snapshot) { + this.reset(); + return { + pid, + cpuPercent: null, + rssBytes: null, + sampledAt: this.reader.now(), + }; + } + + const previous = this.previousPid === pid ? this.previous : null; + this.previous = snapshot; + this.previousPid = pid; + + if (!previous) { + return { pid, cpuPercent: null, rssBytes: snapshot.rssBytes, sampledAt: snapshot.sampledAt }; + } + + const elapsedMs = snapshot.sampledAt - previous.sampledAt; + if (elapsedMs <= 0) { + return { pid, cpuPercent: null, rssBytes: snapshot.rssBytes, sampledAt: snapshot.sampledAt }; + } + + const cpuTicks = this.changedCpuTicks(previous, snapshot); + const cpuSeconds = cpuTicks / this.reader.clockTicksPerSecond; + const elapsedSeconds = elapsedMs / 1000; + const cpuPercent = (cpuSeconds / elapsedSeconds) * 100; + + return { pid, cpuPercent, rssBytes: snapshot.rssBytes, sampledAt: snapshot.sampledAt }; + } + + private async snapshot(rootPid: number): Promise { + const stats = await this.readProcessTree(rootPid); + if (stats.length === 0) return null; + + return { + totalCpuTicks: stats.reduce((sum, stat) => sum + stat.totalCpuTicks, 0), + cpuTicksByPid: new Map(stats.map((stat) => [stat.pid, stat.totalCpuTicks])), + rssBytes: stats.reduce((sum, stat) => sum + stat.rssPages * this.reader.pageSizeBytes, 0), + sampledAt: this.reader.now(), + }; + } + + private changedCpuTicks( + previous: ProcessMetricsSnapshot, + current: ProcessMetricsSnapshot, + ): number { + let changed = 0; + for (const [pid, currentTicks] of current.cpuTicksByPid.entries()) { + const previousTicks = previous.cpuTicksByPid.get(pid); + if (previousTicks === undefined) continue; + changed += Math.max(0, currentTicks - previousTicks); + } + return changed; + } + + private async readProcessTree(rootPid: number): Promise { + const processIds = await this.reader.listProcessIds(); + const stats = ( + await Promise.all(processIds.map((pid) => this.reader.readProcessStat(pid))) + ).filter((stat): stat is ProcessStat => stat !== null); + const childrenByParent = new Map(); + for (const stat of stats) { + const children = childrenByParent.get(stat.parentPid) ?? []; + children.push(stat); + childrenByParent.set(stat.parentPid, children); + } + + const root = stats.find((stat) => stat.pid === rootPid); + if (!root) return []; + + const tree: ProcessStat[] = []; + const queue = [root]; + while (queue.length > 0) { + const stat = queue.shift(); + if (!stat) continue; + tree.push(stat); + queue.push(...(childrenByParent.get(stat.pid) ?? [])); + } + return tree; + } +} + +export const createProcfsMetricsReader = (): ProcessMetricsReader => ({ + now: () => Date.now(), + clockTicksPerSecond: DEFAULT_CLOCK_TICKS_PER_SECOND, + pageSizeBytes: DEFAULT_PAGE_SIZE_BYTES, + cpuCount: cpus().length, + listProcessIds: async () => { + if (process.platform !== "linux") return []; + const entries = await readdir("/proc"); + return entries.map(Number).filter((pid) => Number.isInteger(pid) && pid > 0); + }, + readProcessStat: async (pid) => { + if (process.platform !== "linux") return null; + try { + return parseProcStat(await readFile(join("/proc", String(pid), "stat"), "utf8")); + } catch { + return null; + } + }, +}); + +export const parseProcStat = (value: string): ProcessStat | null => { + const closeParen = value.lastIndexOf(")"); + const openParen = value.indexOf("("); + if (openParen === -1 || closeParen === -1 || closeParen <= openParen) return null; + + const pid = Number(value.slice(0, openParen).trim()); + const rest = value + .slice(closeParen + 2) + .trim() + .split(/\s+/); + const parentPid = Number(rest[1]); + const utime = Number(rest[11]); + const stime = Number(rest[12]); + const rssPages = Number(rest[21]); + + if (![pid, parentPid, utime, stime, rssPages].every(Number.isFinite)) return null; + return { pid, parentPid, totalCpuTicks: utime + stime, rssPages }; +}; + +export const formatBytes = (bytes: number | null): string => { + if (bytes === null) return "—"; + const units = ["B", "KB", "MB", "GB"]; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + const precision = value >= 10 || unitIndex === 0 ? 0 : 1; + return `${value.toFixed(precision)}${units[unitIndex]}`; +}; + +export const formatCpuPercent = (value: number | null): string => + value === null ? "—" : `${value.toFixed(1)}%`; + +export const formatDuration = (startedAt: string | null, now = Date.now()): string => { + if (!startedAt) return "—"; + const started = Date.parse(startedAt); + if (!Number.isFinite(started)) return "—"; + const totalSeconds = Math.max(0, Math.floor((now - started) / 1000)); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes === 0 ? `${hours}h` : `${hours}h ${remainingMinutes}m`; +}; + +export const formatCountdown = (milliseconds: number | null): string => { + if (milliseconds === null) return "—"; + if (milliseconds < 1000) return `${Math.ceil(milliseconds)}ms`; + if (milliseconds < 60_000) return `${(milliseconds / 1000).toFixed(1)}s`; + const totalSeconds = Math.ceil(milliseconds / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`; +}; diff --git a/src/ui.ts b/src/ui.ts index 27b8e09..b748d01 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -11,6 +11,14 @@ import { import type { DiscoverySelection, SelectionItem } from "./discovery"; import type { ExternalRuntimeVisibilityManager } from "./external-runtime"; import type { FocusManager } from "./focus"; +import { + ProcessTreeMetricsSampler, + formatBytes, + formatCountdown, + formatCpuPercent, + formatDuration, + type ProcessMetricsSample, +} from "./process-metrics"; import { getRuntimeStatusView } from "./runtime-status"; import type { ServiceManager, ServiceView } from "./service-manager"; import { formatCommandSpec } from "./shared"; @@ -497,6 +505,46 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo metaText: logPanelMeta, } = createPanel("Logs", "logs"); + const servicePanel = new BoxRenderable(renderer, { + id: "selected-process-panel", + flexShrink: 0, + width: "100%", + flexDirection: "column", + paddingTop: PANEL_PADDING_Y, + paddingBottom: PANEL_PADDING_Y, + paddingLeft: PANEL_PADDING_X, + paddingRight: PANEL_PADDING_X, + rowGap: PANEL_CONTENT_GAP_Y, + backgroundColor: palette.panel, + }); + + const serviceHeading = new BoxRenderable(renderer, { + flexShrink: 0, + width: "100%", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + columnGap: INLINE_GAP_X, + }); + + const servicePanelTitle = new TextRenderable(renderer, { + content: "Process", + fg: palette.muted, + wrapMode: "none", + truncate: true, + }); + + const servicePanelDetail = new TextRenderable(renderer, { + content: "—", + fg: palette.muted, + wrapMode: "none", + truncate: true, + }); + + serviceHeading.add(servicePanelTitle); + servicePanel.add(serviceHeading); + servicePanel.add(servicePanelDetail); + const logList = new ScrollBoxRenderable(renderer, { id: "log-list", flexGrow: 1, @@ -526,12 +574,22 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo }); logPanel.add(logList); + const outputColumn = new BoxRenderable(renderer, { + flexGrow: 1, + minWidth: 0, + height: "100%", + flexDirection: "column", + rowGap: PANEL_GAP_Y, + }); + sideColumn.add(manifestPanel); if (externalPanel) { sideColumn.add(externalPanel); } + outputColumn.add(servicePanel); + outputColumn.add(logPanel); main.add(sideColumn); - main.add(logPanel); + main.add(outputColumn); const footerStack = new BoxRenderable(renderer, { flexShrink: 0, @@ -766,6 +824,42 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo .join(" · "); }; + const serviceDetailSegments = (): string[] => { + const selected = manager.getSelectedView(); + if (!selected) return ["—"]; + + const status = getRuntimeStatusView(selected.runtimeStatus).code; + const pidInfo = selectedProcessPidInfo(); + const pid = pidInfo?.pid ?? null; + const countdown = + selected.runtimeStatus === "retrying" ? [formatCountdown(selected.restartInMs)] : []; + const cpu = pid ? formatCpuPercent(selectedMetrics?.cpuPercent ?? null) : "—"; + const mem = pid ? formatBytes(selectedMetrics?.rssBytes ?? null) : "—"; + const up = pidInfo ? formatDuration(pidInfo.startedAt) : "—"; + const ext = selected.lastExitCode === null ? "—" : formatExit(selected.lastExitCode); + + return [ + status, + ...countdown, + `Pid ${pid ?? "—"}`, + `Cpu ${cpu}`, + `Mem ${mem}`, + `Up ${up}`, + `Ext ${ext}`, + `Rst ${selected.restartCount}`, + ]; + }; + + const rebuildServicePanel = (): void => { + const selected = manager.getSelectedView(); + servicePanelTitle.content = selected ? `Process (${selected.name})` : "Process"; + servicePanelTitle.fg = panelTitleColor("logs"); + servicePanelDetail.content = serviceDetailSegments().join(" "); + servicePanelDetail.fg = selected + ? runtimeStatusColor(selected.runtimeStatus, palette) + : palette.muted; + }; + const footerShortcutBackground = (hovered: boolean): string => hovered ? palette.hover : "transparent"; @@ -1327,6 +1421,13 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo let discoverySelectionLines: TextRenderable[] = []; let discoveryWarningLines: TextRenderable[] = []; let unsubDiscoverySelection: (() => void) | null = null; + const metricsSampler = new ProcessTreeMetricsSampler(); + let selectedMetrics: ProcessMetricsSample | null = null; + let selectedMetricsKey: string | null = null; + let metricsRefreshing = false; + const metricsTimer = setInterval(() => { + void refreshSelectedMetrics(); + }, 1000); const panelTitleColor = (panel: PanelId): string => focusManager.isPanelActive(panel) ? palette.accent : palette.muted; @@ -1334,6 +1435,32 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo const panelBackgroundColor = (panel: PanelId): string => focusManager.isPanelActive(panel) ? palette.panelActive : palette.panel; + const selectedProcessPidInfo = () => { + const selected = manager.getSelectedView(); + if (!selected) return null; + return manager.getServicePids().find((entry) => entry.name === selected.name) ?? null; + }; + + const refreshSelectedMetrics = async (): Promise => { + if (metricsRefreshing) return; + metricsRefreshing = true; + try { + const selected = manager.getSelectedView(); + const pidInfo = selectedProcessPidInfo(); + const key = selected && pidInfo ? `${selected.name}:${pidInfo.pid}` : null; + if (key !== selectedMetricsKey) { + selectedMetricsKey = key; + metricsSampler.reset(); + selectedMetrics = null; + } + + selectedMetrics = await metricsSampler.sample(pidInfo?.pid ?? null); + renderAll(); + } finally { + metricsRefreshing = false; + } + }; + const listSelectionBackground = (): string => palette.selection; const listHoverBackground = (): string => palette.hover; @@ -1957,6 +2084,7 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo logPanelTitle.content = selectedLogName ? `Logs (${selectedLogName})` : "Logs"; logPanelTitle.fg = panelTitleColor("logs"); const logsBackground = panelBackgroundColor("logs"); + servicePanel.backgroundColor = logsBackground; logPanel.backgroundColor = logsBackground; logList.backgroundColor = logsBackground; logList.wrapper.backgroundColor = logsBackground; @@ -1988,6 +2116,7 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo rebuildList(views, manager.getSelectedIndex()); rebuildExternalList(); rebuildLogs(); + rebuildServicePanel(); updateHeader(); updatePanelStyles(); rebuildFooter(); @@ -2019,6 +2148,8 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo } sideColumn.visible = sidePanelsVisible; logsPanelVisible = nextLogsPanelVisible; + outputColumn.visible = logsPanelVisible; + servicePanel.visible = logsPanelVisible; logPanel.visible = logsPanelVisible; focusManager.ensureActivePanelVisible(getRenderedPanels()); @@ -2436,6 +2567,7 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo const teardown = () => { renderer.off("theme_mode", applyTheme); renderer.off("resize", applyLayout); + clearInterval(metricsTimer); unsubManager(); unsubFocus(); unsubscribeExternalRuntime();