feat(FN-3211): document app CPU metric in system stats

Updated the architecture documentation to document the app CPU metric in the system stats section.

Fusion-Task-Id: FN-3211
This commit is contained in:
Fusion
2026-05-04 15:11:17 -07:00
committed by gsxdsm
parent e87961825f
commit 6dfe7c5e09
8 changed files with 133 additions and 12 deletions

View File

@@ -17,11 +17,6 @@ import { homedir } from "node:os";
import { createRequire } from "node:module";
const processRef = globalThis.process;
const fetchRef = globalThis.fetch;
const abortControllerRef = globalThis.AbortController;
const setTimeoutRef = globalThis.setTimeout;
const clearTimeoutRef = globalThis.clearTimeout;
const args = processRef.argv.slice(2);
const invokedAs = basename(processRef.argv[1] || "").replace(/\.(js|cjs|mjs|exe)$/i, "");
const isAliasInvocation = invokedAs === "runfusion.ai" || invokedAs === "runfusion";

View File

@@ -5717,6 +5717,7 @@ export interface SystemStatsSnapshot {
heapLimit: number;
external: number;
arrayBuffers: number;
// Null until at least two samples are available to compute process CPU delta.
cpuPercent: number | null;
loadAvg: [number, number, number];
cpuCount: number;

View File

@@ -113,12 +113,14 @@
gap: var(--space-md);
}
.system-stats-modal__row--memory-used {
.system-stats-modal__row--memory-used,
.system-stats-modal__row--cpu-used {
align-items: stretch;
flex-direction: column;
}
.system-stats-modal__row--memory-used dd {
.system-stats-modal__row--memory-used dd,
.system-stats-modal__row--cpu-used dd {
width: 100%;
justify-content: flex-end;
}

View File

@@ -54,6 +54,14 @@ function systemMemSeverity(used: number, total: number): Severity {
return "normal";
}
function cpuSeverity(percent: number | null, cores: number): Severity {
if (percent === null || !Number.isFinite(percent) || percent < 0) return "normal";
const normalized = cores > 0 ? percent / cores : percent;
if (normalized >= 80) return "critical";
if (normalized >= 50) return "warning";
return "normal";
}
function severityClassName(severity: Severity): string {
if (severity === "critical") return "system-stats-modal__value--critical";
if (severity === "warning") return "system-stats-modal__value--warning";
@@ -219,6 +227,15 @@ export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModa
? `System memory used: ${usedSystemMemPercent.toFixed(1)}% (${formatBytes(usedSystemMem)} of ${formatBytes(system.systemTotalMem)})`
: "System memory usage unavailable";
const vitestProcessCount = stats?.vitestProcessCount;
const cpuLoadSeverity = cpuSeverity(system?.cpuPercent ?? null, system?.cpuCount ?? 0);
const cpuClassName = severityClassName(cpuLoadSeverity);
const cpuPercentValue = system?.cpuPercent ?? null;
const cpuBarPercent = cpuPercentValue === null ? 0 : Math.max(0, Math.min(100, cpuPercentValue));
const cpuPercentLabel = cpuPercentValue === null ? "Sampling…" : `${cpuPercentValue.toFixed(1)}%`;
const cpuProgressLabel =
cpuPercentValue === null
? "App CPU usage unavailable: waiting for another sample"
: `App CPU usage: ${cpuPercentValue.toFixed(1)}%`;
const killResultClassName = killResult
? killResult.killed > 0
? "system-stats-modal__kill-result system-stats-modal__kill-result--success"
@@ -294,6 +311,28 @@ export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModa
<section className="system-stats-modal__section" aria-label="CPU and load stats">
<h3 className="system-stats-modal__section-title">CPU &amp; Load</h3>
<dl className="system-stats-modal__grid">
<div className="system-stats-modal__row system-stats-modal__row--cpu-used">
<dt>App CPU</dt>
<dd>
<span className={`system-stats-modal__value ${cpuClassName}`.trim()}>{cpuPercentLabel}</span>
<span className="system-stats-modal__detail">{cpuPercentValue === null ? "First sample pending" : "process usage"}</span>
</dd>
<div className="system-stats-modal__memory-progress-wrapper">
<div
className={`system-stats-modal__memory-progress-track system-stats-modal__memory-progress-track--${cpuLoadSeverity}`}
role="progressbar"
aria-valuenow={Math.round(cpuBarPercent)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={cpuProgressLabel}
>
<div
className={`system-stats-modal__memory-progress-fill system-stats-modal__memory-progress-fill--${cpuLoadSeverity}`}
style={{ width: `${cpuBarPercent}%` }}
/>
</div>
</div>
</div>
<div className="system-stats-modal__row">
<dt>Load Avg</dt>
<dd>{system?.loadAvg.map((value) => value.toFixed(2)).join(" ") ?? "—"}</dd>

View File

@@ -30,7 +30,7 @@ const sampleStats = {
heapLimit: 1000 * 1024 * 1024,
external: 50 * 1024 * 1024,
arrayBuffers: 20 * 1024 * 1024,
cpuPercent: null,
cpuPercent: 68.4,
loadAvg: [1.2, 0.8, 0.5] as [number, number, number],
cpuCount: 8,
systemTotalMem: 10 * 1024 * 1024 * 1024,
@@ -116,6 +116,13 @@ describe("SystemStatsModal", () => {
const criticalFill = memoryUsageProgress.querySelector(".system-stats-modal__memory-progress-fill");
expect(criticalFill?.className).toContain("system-stats-modal__memory-progress-fill--critical");
expect(screen.getByText("68.4%")).toBeDefined();
const cpuUsageProgress = screen.getByRole("progressbar", {
name: "App CPU usage: 68.4%",
});
expect(cpuUsageProgress).toHaveAttribute("aria-valuenow", "68");
expect(cpuUsageProgress.className).toContain("system-stats-modal__memory-progress-track--normal");
expect(screen.getByText("1.20 0.80 0.50")).toBeDefined();
expect(screen.getByText("Vitest Processes")).toBeDefined();
expect(screen.getByText(/Last auto-kill:/)).toBeDefined();
@@ -267,6 +274,24 @@ describe("SystemStatsModal", () => {
});
});
it("shows deterministic fallback copy when app CPU percentage is unavailable", async () => {
mockFetchSystemStats.mockResolvedValue({
...sampleStats,
systemStats: {
...sampleStats.systemStats,
cpuPercent: null,
},
});
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
expect(await screen.findByText("Sampling…")).toBeDefined();
const cpuUsageProgress = screen.getByRole("progressbar", {
name: "App CPU usage unavailable: waiting for another sample",
});
expect(cpuUsageProgress).toHaveAttribute("aria-valuenow", "0");
});
it("shows fallback text when last auto-kill timestamp is unavailable", async () => {
mockFetchSystemStats.mockResolvedValue({
...sampleStats,

View File

@@ -321,6 +321,22 @@ describe("GET /api/system-stats", () => {
}
it("returns process/system metrics with task and agent aggregates", async () => {
const cpuUsageSpy = vi.spyOn(process, "cpuUsage");
const dateNowSpy = vi.spyOn(Date, "now");
cpuUsageSpy
.mockReturnValueOnce({ user: 1_000_000, system: 500_000 })
.mockImplementation((previousValue?: NodeJS.CpuUsage) => {
if (previousValue) {
return { user: 200_000, system: 100_000 };
}
return { user: 1_200_000, system: 600_000 };
});
let now = 1_000;
dateNowSpy.mockImplementation(() => {
now += 1_000;
return now;
});
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([
{ id: "FN-1", column: "triage" },
@@ -330,7 +346,7 @@ describe("GET /api/system-stats", () => {
getFusionDir: vi.fn().mockReturnValue("/fake/default"),
});
mockExecFile.mockImplementationOnce((...callArgs: unknown[]) => {
mockExecFile.mockImplementation((...callArgs: unknown[]) => {
const cb = callArgs[callArgs.length - 1] as (err: unknown, stdout?: string, stderr?: string) => void;
cb(null, `${process.pid}\n111\n222\n`, "");
});
@@ -343,7 +359,8 @@ describe("GET /api/system-stats", () => {
{ id: "agent-4", state: "error" },
] as Array<Awaited<ReturnType<AgentStore["listAgents"]>>[number]>);
const res = await GET(buildApp(store), "/api/system-stats");
const app = buildApp(store);
const res = await GET(app, "/api/system-stats");
expect(res.status).toBe(200);
expect(res.body.systemStats).toEqual(
@@ -364,6 +381,11 @@ describe("GET /api/system-stats", () => {
platform: expect.stringContaining("/"),
}),
);
const secondRes = await GET(app, "/api/system-stats");
expect(secondRes.status).toBe(200);
expect(secondRes.body.systemStats.cpuPercent).toBe(30);
expect(res.body.taskStats).toEqual({
total: 3,
byColumn: {
@@ -384,6 +406,9 @@ describe("GET /api/system-stats", () => {
});
expect(res.body.vitestProcessCount).toBe(2);
expect(res.body.vitestLastAutoKillAt).toBeNull();
cpuUsageSpy.mockRestore();
dateNowSpy.mockRestore();
mockExecFile.mockClear();
});
@@ -451,6 +476,7 @@ describe("GET /api/system-stats", () => {
expect.objectContaining({
rss: expect.any(Number),
heapUsed: expect.any(Number),
cpuPercent: expect.toSatisfy((value: unknown) => value === null || typeof value === "number"),
}),
);
expect(res.body.taskStats).toEqual({

View File

@@ -1303,6 +1303,38 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
let lastCpuUsageSample: NodeJS.CpuUsage | null = null;
let lastCpuSampleAt: number | null = null;
const getAppCpuPercent = (): number | null => {
const currentCpuUsage = process.cpuUsage();
const currentSampleAt = Date.now();
if (lastCpuUsageSample === null || lastCpuSampleAt === null) {
lastCpuUsageSample = { user: currentCpuUsage.user, system: currentCpuUsage.system };
lastCpuSampleAt = currentSampleAt;
return null;
}
const elapsedMs = currentSampleAt - lastCpuSampleAt;
const cpuUsageDelta = process.cpuUsage(lastCpuUsageSample);
lastCpuUsageSample = { user: currentCpuUsage.user, system: currentCpuUsage.system };
lastCpuSampleAt = currentSampleAt;
if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) {
return null;
}
const elapsedMicros = elapsedMs * 1_000;
const usedMicros = cpuUsageDelta.user + cpuUsageDelta.system;
if (!Number.isFinite(usedMicros) || usedMicros < 0) {
return null;
}
return Math.max(0, Number(((usedMicros / elapsedMicros) * 100).toFixed(1)));
};
const getVitestProcessIds = async (): Promise<number[]> => {
// execFile (not execSync) so the dashboard's event loop stays responsive
// while pgrep walks the process table — that walk can take 100ms+ on a
@@ -1332,6 +1364,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const heapStats = v8.getHeapStatistics();
const load = os.loadavg();
const vitestProcessIds = await getVitestProcessIds();
const cpuPercent = getAppCpuPercent();
let totalTasks = 0;
let activeTasks = 0;
@@ -1390,7 +1423,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
heapLimit: heapStats.heap_size_limit,
external: mem.external,
arrayBuffers: mem.arrayBuffers,
cpuPercent: null,
cpuPercent,
loadAvg: [load[0] ?? 0, load[1] ?? 0, load[2] ?? 0],
cpuCount: os.cpus().length,
systemTotalMem: os.totalmem(),