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:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user