FN-6835: add system stats node selection

Add Command Center controls for inspecting local or remote node system telemetry.

- add a System area node selector with local defaults, remote stats loading, and per-node sample resets
- expose proxied node system-stats fetching and route local node requests through the shared stats builder
- route Vitest kill requests through node-aware proxy plumbing and cover node stats behavior with tests
- add localized node selector copy, docs, and a published package changeset

Files changed:
 .changeset/fuzzy-nodes-watch.md                    |   5 +
 docs/dashboard-guide.md                            |   4 +-
 packages/dashboard/app/api/legacy.ts               |  14 +-
 .../__tests__/CommandCenter.mobile-scroll.test.tsx |   2 +
 .../__tests__/CommandCenter.tablet-layout.test.tsx |   2 +
 .../__tests__/CommandCenter.test.tsx               |   1 +
 .../__tests__/SystemStatsArea.test.tsx             | 160 +++++++++++++++++
 .../command-center/areas/SystemStatsArea.css       |  13 ++
 .../command-center/areas/SystemStatsArea.tsx       |  72 +++++++-
 .../dashboard/src/__tests__/routes-system.test.ts  | 122 +++++++++++++
 packages/dashboard/src/routes.ts                   | 190 +++++++++++++--------
 packages/i18n/locales/en/app.json                  |  10 ++
 packages/i18n/locales/es/app.json                  |  10 ++
 packages/i18n/locales/fr/app.json                  |  10 ++
 packages/i18n/locales/ko/app.json                  |  10 ++
 packages/i18n/locales/zh-CN/app.json               |  10 ++
 packages/i18n/locales/zh-TW/app.json               |  10 ++
 17 files changed, 562 insertions(+), 83 deletions(-)

Fusion-Task-Id: FN-6835

Fusion-Task-Lineage: 8f9a047b-78d0-477e-824e-f7592966cdc0
This commit is contained in:
gsxdsm
2026-06-21 12:54:36 -07:00
parent ef44f6357f
commit eb3477aba4
17 changed files with 564 additions and 85 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add a Command Center System node selector so local and registered remote node telemetry can be inspected from the dashboard.

View File

@@ -694,7 +694,7 @@ Features:
<!-- FNXC:CommandCenter 2026-06-21-07:07: FN-6722 requires the GitHub area to expose a resolved-issue detail list from local task-store analytics only, with exact close timestamps flagged when reconciliation populated `sourceIssueClosedAt` and approximation called out otherwise. --> <!-- FNXC:CommandCenter 2026-06-21-07:07: FN-6722 requires the GitHub area to expose a resolved-issue detail list from local task-store analytics only, with exact close timestamps flagged when reconciliation populated `sourceIssueClosedAt` and approximation called out otherwise. -->
- **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, a filed-vs-fixed pie, a filed/fixed recharts trend line, existing daily sparklines, a by-repository bar breakdown, and a **Resolved issues** detail list. Resolved rows include the Fusion task, repository, source issue number, optional issue link, resolved timestamp, and whether that timestamp is exact (`sourceIssueClosedAt`) or the documented `updatedAt` approximation; missing issue URLs render as plain text rather than empty anchors or click targets. The same resolved rows are available from the GitHub analytics payload as `resolved` and from the CSV export. - **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, a filed-vs-fixed pie, a filed/fixed recharts trend line, existing daily sparklines, a by-repository bar breakdown, and a **Resolved issues** detail list. Resolved rows include the Fusion task, repository, source issue number, optional issue link, resolved timestamp, and whether that timestamp is exact (`sourceIssueClosedAt`) or the documented `updatedAt` approximation; missing issue URLs render as plain text rather than empty anchors or click targets. The same resolved rows are available from the GitHub analytics payload as `resolved` and from the CSV export.
- **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. It shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns; an empty incidents table renders honest zero counts with MTTR unavailable rather than fabricated signal volume. It adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. External connectors that ingest third-party signals into incidents are tracked separately in FN-6706. - **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. It shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns; an empty incidents table renders honest zero counts with MTTR unavailable rather than fabricated signal volume. It adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. External connectors that ingest third-party signals into incidents are tracked separately in FN-6706.
- **System** is the canonical system-telemetry destination. It reuses `GET /api/system-stats` with no new endpoint, renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory/heap trend sparklines, adds a recharts CPU/memory/heap line from that same rolling buffer, and adds a task-by-column pie alongside the existing tasks-by-column and agents-by-state bars. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed. - **System** is the canonical system-telemetry destination. It reads local telemetry from `GET /api/system-stats` and, when multiple registered nodes exist, shows a node selector that can proxy the same system-stats payload through `GET /api/nodes/:id/system-stats` for remote nodes. It renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory/heap trend sparklines, adds a recharts CPU/memory/heap line from that same rolling buffer, and adds a task-by-column pie alongside the existing tasks-by-column and agents-by-state bars. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed.
- **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. No additional pie or line chart is rendered because the live SDLC funnel already visualizes the panel's only quantitative distribution (`snapshot.columns`), while sessions/nodes are live control lists rather than categorical analytics. Motion-heavy accents respect reduced-motion preferences. - **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. No additional pie or line chart is rendered because the live SDLC funnel already visualizes the panel's only quantitative distribution (`snapshot.columns`), while sessions/nodes are live control lists rather than categorical analytics. Motion-heavy accents respect reduced-motion preferences.
- CSV exports are available from the analytics endpoints with `?format=csv`. The Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`. - CSV exports are available from the analytics endpoints with `?format=csv`. The Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`.
@@ -710,7 +710,7 @@ Data states:
- Overview shows a loading state while core analytics settle, then shows `No usage data yet. Run some agents to populate the Command Center.` only after the selected range has settled with no core usage data. Overview, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, System, and Reliability omit their additive recharts cards in loading/error/empty states, so non-populated data never leaves an empty chart shell. - Overview shows a loading state while core analytics settle, then shows `No usage data yet. Run some agents to populate the Command Center.` only after the selected range has settled with no core usage data. Overview, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, System, and Reliability omit their additive recharts cards in loading/error/empty states, so non-populated data never leaves an empty chart shell.
- GitHub issue analytics is local and additive: empty filed/fixed totals keep the stat cards and historical backfill button available while omitting empty chart shells; malformed historical `githubTracking` JSON is skipped instead of breaking the Command Center. - GitHub issue analytics is local and additive: empty filed/fixed totals keep the stat cards and historical backfill button available while omitting empty chart shells; malformed historical `githubTracking` JSON is skipped instead of breaking the Command Center.
- Team analytics renders its shared loading/error/empty states for null or zero-agent responses, omits empty chart shells for zero-value datasets, and keeps the Command Center tab panel as the mobile scroll owner. - Team analytics renders its shared loading/error/empty states for null or zero-agent responses, omits empty chart shells for zero-value datasets, and keeps the Command Center tab panel as the mobile scroll owner.
- System telemetry keeps the previous snapshot visible during refresh failures, renders a first-sample CPU `Sampling…` state without NaN values, shows zero-value task/agent bars for empty collections while omitting the zero-value task-distribution pie, and keeps the Command Center tab panel as the mobile scroll owner. - System telemetry keeps the previous snapshot visible during refresh failures, preserves the node selector when a selected remote node fails to refresh, renders a first-sample CPU `Sampling…` state without NaN values, shows zero-value task/agent bars for empty collections while omitting the zero-value task-distribution pie, and keeps the Command Center tab panel as the mobile scroll owner.
- Signals is best-effort over local incidents data: if the project has no incidents, the Signals area shows its empty state, omits its status pie, and other Command Center metrics remain valid; endpoint errors surface as the shared analytics error state instead of silently swallowing a missing route. - Signals is best-effort over local incidents data: if the project has no incidents, the Signals area shows its empty state, omits its status pie, and other Command Center metrics remain valid; endpoint errors surface as the shared analytics error state instead of silently swallowing a missing route.
## Reliability View ## Reliability View

View File

@@ -7213,13 +7213,23 @@ export interface GithubSourceIssueClosedAtBackfillResult {
hasMore: boolean; hasMore: boolean;
} }
/*
FNXC:CommandCenter 2026-06-21-00:00:
The Command Center System area keeps the direct local /system-stats client and uses the explicit /nodes/:id/system-stats route for selected remote nodes so authenticated node proxying stays server-side and local project scoping is not forwarded across nodes.
*/
export function fetchSystemStats(projectId?: string): Promise<SystemStatsResponse> { export function fetchSystemStats(projectId?: string): Promise<SystemStatsResponse> {
return api<SystemStatsResponse>(withProjectId("/system-stats", projectId)); return api<SystemStatsResponse>(withProjectId("/system-stats", projectId));
} }
export function killVitestProcesses(projectId?: string): Promise<KillVitestResponse> { export function fetchNodeSystemStats(nodeId: string, projectId?: string): Promise<SystemStatsResponse> {
return api<KillVitestResponse>(withProjectId("/kill-vitest", projectId), { return api<SystemStatsResponse>(withProjectId(`/nodes/${encodeURIComponent(nodeId)}/system-stats`, projectId));
}
export function killVitestProcesses(projectId?: string, nodeId?: string, localNodeId?: string): Promise<KillVitestResponse> {
return proxyApi<KillVitestResponse>(withProjectId("/kill-vitest", projectId), {
method: "POST", method: "POST",
nodeId,
localNodeId,
}); });
} }

View File

@@ -28,7 +28,9 @@ vi.mock("../../../hooks/useAppSettings", () => ({
vi.mock("../../../api", () => ({ vi.mock("../../../api", () => ({
fetchSystemStats: () => Promise.resolve(systemStatsFixture()), fetchSystemStats: () => Promise.resolve(systemStatsFixture()),
fetchNodeSystemStats: () => Promise.resolve(systemStatsFixture()),
fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }), fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }),
fetchNodes: () => Promise.resolve([]),
killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }), killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }),
updateGlobalSettings: () => Promise.resolve({}), updateGlobalSettings: () => Promise.resolve({}),
})); }));

View File

@@ -17,7 +17,9 @@ This test renders the real useAppSettings hook rather than mocking it, so the ..
*/ */
vi.mock("../../../api", () => ({ vi.mock("../../../api", () => ({
fetchSystemStats: () => Promise.resolve(systemStatsFixture()), fetchSystemStats: () => Promise.resolve(systemStatsFixture()),
fetchNodeSystemStats: () => Promise.resolve(systemStatsFixture()),
fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }), fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }),
fetchNodes: () => Promise.resolve([]),
fetchConfig: vi.fn().mockResolvedValue({ maxConcurrent: 2, rootDir: "/" }), fetchConfig: vi.fn().mockResolvedValue({ maxConcurrent: 2, rootDir: "/" }),
fetchSettings: vi.fn().mockResolvedValue({ autoMerge: false, globalPause: false, enginePaused: false }), fetchSettings: vi.fn().mockResolvedValue({ autoMerge: false, globalPause: false, enginePaused: false }),
killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }), killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }),

View File

@@ -28,6 +28,7 @@ vi.mock("../../../hooks/useAppSettings", () => ({
vi.mock("../../../api", () => ({ vi.mock("../../../api", () => ({
fetchSystemStats: () => Promise.resolve(systemStatsFixture()), fetchSystemStats: () => Promise.resolve(systemStatsFixture()),
fetchNodeSystemStats: () => Promise.resolve(systemStatsFixture()),
fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }), fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }),
killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }), killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }),
updateGlobalSettings: () => Promise.resolve({}), updateGlobalSettings: () => Promise.resolve({}),

View File

@@ -1,16 +1,22 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import { SystemStatsArea } from "../areas/SystemStatsArea"; import { SystemStatsArea } from "../areas/SystemStatsArea";
const mockFetchSystemStats = vi.fn(); const mockFetchSystemStats = vi.fn();
const mockFetchNodeSystemStats = vi.fn();
const mockFetchGlobalSettings = vi.fn(); const mockFetchGlobalSettings = vi.fn();
const mockFetchNodes = vi.fn();
const mockKillVitestProcesses = vi.fn(); const mockKillVitestProcesses = vi.fn();
const mockUpdateGlobalSettings = vi.fn(); const mockUpdateGlobalSettings = vi.fn();
vi.mock("../../../api", () => ({ vi.mock("../../../api", () => ({
fetchSystemStats: (...args: unknown[]) => mockFetchSystemStats(...args), fetchSystemStats: (...args: unknown[]) => mockFetchSystemStats(...args),
fetchNodeSystemStats: (...args: unknown[]) => mockFetchNodeSystemStats(...args),
fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args), fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args),
fetchNodes: (...args: unknown[]) => mockFetchNodes(...args),
killVitestProcesses: (...args: unknown[]) => mockKillVitestProcesses(...args), killVitestProcesses: (...args: unknown[]) => mockKillVitestProcesses(...args),
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args), updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
})); }));
@@ -18,6 +24,16 @@ vi.mock("../../../api", () => ({
const gb = 1024 * 1024 * 1024; const gb = 1024 * 1024 * 1024;
const mb = 1024 * 1024; const mb = 1024 * 1024;
type NodeFixture = {
id: string;
name: string;
type: "local" | "remote";
status: "online";
maxConcurrent: number;
createdAt: string;
updatedAt: string;
};
type SystemStatsFixture = ReturnType<typeof baseStats>; type SystemStatsFixture = ReturnType<typeof baseStats>;
type SystemStatsFixtureOverrides = Partial<Omit<SystemStatsFixture, "systemStats" | "taskStats">> & { type SystemStatsFixtureOverrides = Partial<Omit<SystemStatsFixture, "systemStats" | "taskStats">> & {
systemStats?: Partial<SystemStatsFixture["systemStats"]>; systemStats?: Partial<SystemStatsFixture["systemStats"]>;
@@ -45,6 +61,10 @@ function sampleStats(overrides: SystemStatsFixtureOverrides = {}) {
}; };
} }
function nodeFixture(id: string, name: string, type: "local" | "remote"): NodeFixture {
return { id, name, type, status: "online", maxConcurrent: 1, createdAt: "", updatedAt: "" };
}
function baseStats() { function baseStats() {
return { return {
systemStats: { systemStats: {
@@ -89,7 +109,9 @@ describe("SystemStatsArea", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockFetchSystemStats.mockResolvedValue(sampleStats()); mockFetchSystemStats.mockResolvedValue(sampleStats());
mockFetchNodeSystemStats.mockResolvedValue(sampleStats());
mockFetchGlobalSettings.mockResolvedValue({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }); mockFetchGlobalSettings.mockResolvedValue({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 });
mockFetchNodes.mockResolvedValue([]);
mockKillVitestProcesses.mockResolvedValue({ killed: 2, pids: [111, 222] }); mockKillVitestProcesses.mockResolvedValue({ killed: 2, pids: [111, 222] });
mockUpdateGlobalSettings.mockResolvedValue({}); mockUpdateGlobalSettings.mockResolvedValue({});
}); });
@@ -213,6 +235,144 @@ describe("SystemStatsArea", () => {
}); });
}); });
it("renders a node selector with local and remote nodes", async () => {
mockFetchNodes.mockResolvedValue([
nodeFixture("local-node", "Local", "local"),
nodeFixture("remote-a", "Remote A", "remote"),
nodeFixture("remote-b", "Remote B", "remote"),
]);
render(<SystemStatsArea projectId="proj-1" />);
const selector = await screen.findByTestId("cc-system-node-select");
expect(selector).toHaveAccessibleName("Select system stats node");
expect(within(selector).getByRole("option", { name: "Local (this node)" })).toBeInTheDocument();
expect(within(selector).getByRole("option", { name: "Remote A" })).toBeInTheDocument();
expect(within(selector).getByRole("option", { name: "Remote B" })).toBeInTheDocument();
expect(screen.getByText("Viewing Local")).toBeInTheDocument();
});
it("routes remote stats and Vitest kills with the selected node id while local calls stay local", async () => {
mockFetchNodes.mockResolvedValue([
nodeFixture("local-node", "Local", "local"),
nodeFixture("remote-a", "Remote A", "remote"),
]);
render(<SystemStatsArea projectId="proj-1" />);
const selector = await screen.findByTestId("cc-system-node-select");
await waitFor(() => {
expect(mockFetchSystemStats).toHaveBeenCalledWith("proj-1");
});
expect(mockFetchSystemStats).not.toHaveBeenCalledWith("proj-1", "local-node", "local-node");
mockFetchSystemStats.mockClear();
fireEvent.change(selector, { target: { value: "remote-a" } });
await waitFor(() => {
expect(mockFetchNodeSystemStats).toHaveBeenCalledWith("remote-a", "proj-1");
});
expect(mockFetchSystemStats).not.toHaveBeenCalledWith("proj-1", "remote-a", "local-node");
expect(screen.getByText("Viewing Remote A")).toBeInTheDocument();
const killButton = screen.getByTestId("cc-system-kill-vitest");
fireEvent.click(killButton);
fireEvent.click(killButton);
await waitFor(() => {
expect(mockKillVitestProcesses).toHaveBeenCalledWith("proj-1", "remote-a", "local-node");
});
});
it("shows a remote fetch error while keeping the node selector usable", async () => {
mockFetchNodes.mockResolvedValue([
nodeFixture("local-node", "Local", "local"),
nodeFixture("remote-a", "Remote A", "remote"),
]);
mockFetchNodeSystemStats.mockRejectedValueOnce(new Error("remote offline"));
render(<SystemStatsArea projectId="proj-1" />);
const selector = await screen.findByTestId("cc-system-node-select");
await waitFor(() => expect(mockFetchSystemStats).toHaveBeenCalledWith("proj-1"));
fireEvent.change(selector, { target: { value: "remote-a" } });
expect(await screen.findByText("Latest refresh failed: remote offline")).toBeInTheDocument();
expect(screen.getByTestId("cc-system-node-select")).toBeInTheDocument();
mockFetchSystemStats.mockClear();
fireEvent.change(selector, { target: { value: "local-node" } });
await waitFor(() => expect(mockFetchSystemStats).toHaveBeenCalledWith("proj-1"));
});
it("resets the rolling sample buffer and re-fetches when switching nodes", async () => {
mockFetchNodes.mockResolvedValue([
nodeFixture("local-node", "Local", "local"),
nodeFixture("remote-a", "Remote A", "remote"),
]);
mockFetchSystemStats
.mockResolvedValueOnce(sampleStats({ systemStats: { cpuPercent: 10, systemFreeMem: 9 * gb, heapUsed: 100 * mb } }))
.mockResolvedValueOnce(sampleStats({ systemStats: { cpuPercent: 20, systemFreeMem: 8 * gb, heapUsed: 200 * mb } }))
.mockResolvedValue(sampleStats({ systemStats: { cpuPercent: 30, systemFreeMem: 7 * gb, heapUsed: 300 * mb } }));
mockFetchNodeSystemStats.mockResolvedValue(sampleStats({ systemStats: { cpuPercent: 70, systemFreeMem: 3 * gb, heapUsed: 700 * mb } }));
const { container } = render(<SystemStatsArea projectId="proj-1" />);
const selector = await screen.findByTestId("cc-system-node-select");
await waitFor(() => {
expect(container.querySelectorAll("[data-testid='cc-system-cpu-trend'] .cc-sparkline-bar").length).toBeGreaterThan(0);
});
fireEvent.change(selector, { target: { value: "remote-a" } });
await waitFor(() => {
expect(mockFetchNodeSystemStats).toHaveBeenCalledWith("remote-a", "proj-1");
expect(container.querySelectorAll("[data-testid='cc-system-cpu-trend'] .cc-sparkline-bar")).toHaveLength(1);
});
});
it("hides the node selector for local-only, empty, and failed node lists while telemetry still loads", async () => {
mockFetchNodes.mockResolvedValueOnce([nodeFixture("local-node", "Local", "local")]);
const { unmount } = render(<SystemStatsArea projectId="proj-1" />);
await screen.findByTestId("cc-area-system");
await waitFor(() => expect(mockFetchNodes).toHaveBeenCalledTimes(1));
expect(screen.queryByTestId("cc-system-node-select")).toBeNull();
expect(mockFetchSystemStats).toHaveBeenCalledWith("proj-1");
unmount();
vi.clearAllMocks();
mockFetchSystemStats.mockResolvedValue(sampleStats());
mockFetchNodeSystemStats.mockResolvedValue(sampleStats());
mockFetchGlobalSettings.mockResolvedValue({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 });
mockFetchNodes.mockResolvedValueOnce([]);
const { unmount: unmountEmpty } = render(<SystemStatsArea projectId="proj-1" />);
await screen.findByTestId("cc-area-system");
await waitFor(() => expect(mockFetchNodes).toHaveBeenCalledTimes(1));
expect(screen.queryByTestId("cc-system-node-select")).toBeNull();
expect(mockFetchSystemStats).toHaveBeenCalledWith("proj-1");
unmountEmpty();
vi.clearAllMocks();
mockFetchSystemStats.mockResolvedValue(sampleStats());
mockFetchNodeSystemStats.mockResolvedValue(sampleStats());
mockFetchGlobalSettings.mockResolvedValue({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 });
mockFetchNodes.mockRejectedValueOnce(new Error("nodes unavailable"));
render(<SystemStatsArea projectId="proj-2" />);
await screen.findByTestId("cc-area-system");
await waitFor(() => expect(mockFetchNodes).toHaveBeenCalledTimes(1));
expect(screen.queryByTestId("cc-system-node-select")).toBeNull();
expect(mockFetchSystemStats).toHaveBeenCalledWith("proj-2");
});
it("keeps the node selector inside the mobile System-area layout contract", () => {
const css = readFileSync(join(process.cwd(), "app/components/command-center/areas/SystemStatsArea.css"), "utf8");
expect(css).toContain("@media (max-width: 768px)");
expect(css).toContain(".cc-system-node-selector");
expect(css).toContain("inline-size: 100%");
});
it("polls every five seconds and clears the interval on unmount", async () => { it("polls every five seconds and clears the interval on unmount", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { unmount } = render(<SystemStatsArea />); const { unmount } = render(<SystemStatsArea />);

View File

@@ -16,6 +16,17 @@ The Command Center System area replaces the standalone System Stats modal with g
flex: 0 0 auto; flex: 0 0 auto;
} }
.cc-system-node-selector {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
color: var(--text);
}
.cc-system-node-selector .input {
min-inline-size: 10rem;
}
.cc-system-gauges .cc-stat-card { .cc-system-gauges .cc-stat-card {
min-block-size: 100%; min-block-size: 100%;
} }
@@ -114,6 +125,7 @@ System control cards sit beside chart/stat cards, so FN-6680 gives them the same
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.cc-system-node-selector,
.cc-system-vitest-card, .cc-system-vitest-card,
.cc-system-toggle-row, .cc-system-toggle-row,
.cc-system-threshold-row, .cc-system-threshold-row,
@@ -123,6 +135,7 @@ System control cards sit beside chart/stat cards, so FN-6680 gives them the same
inline-size: 100%; inline-size: 100%;
} }
.cc-system-node-selector .input,
.cc-system-vitest-card .btn, .cc-system-vitest-card .btn,
.cc-system-threshold-controls .input { .cc-system-threshold-controls .input {
inline-size: 100%; inline-size: 100%;

View File

@@ -3,12 +3,14 @@ import { useTranslation } from "react-i18next";
import { RefreshCw, ShieldAlert, Skull } from "lucide-react"; import { RefreshCw, ShieldAlert, Skull } from "lucide-react";
import { import {
fetchGlobalSettings, fetchGlobalSettings,
fetchNodeSystemStats,
fetchSystemStats, fetchSystemStats,
killVitestProcesses, killVitestProcesses,
updateGlobalSettings, updateGlobalSettings,
type KillVitestResponse, type KillVitestResponse,
type SystemStatsResponse, type SystemStatsResponse,
} from "../../../api"; } from "../../../api";
import { useNodes } from "../../../hooks/useNodes";
import { Bar, type BarDatum } from "../charts/Bar"; import { Bar, type BarDatum } from "../charts/Bar";
import { RadialGauge } from "../charts/RadialGauge"; import { RadialGauge } from "../charts/RadialGauge";
import { Sparkline } from "../charts/Sparkline"; import { Sparkline } from "../charts/Sparkline";
@@ -125,11 +127,35 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {
const [killResult, setKillResult] = useState<KillVitestResponse | null>(null); const [killResult, setKillResult] = useState<KillVitestResponse | null>(null);
const [settingsError, setSettingsError] = useState<string | null>(null); const [settingsError, setSettingsError] = useState<string | null>(null);
const [lastRefreshedAt, setLastRefreshedAt] = useState<number | null>(null); const [lastRefreshedAt, setLastRefreshedAt] = useState<number | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const { nodes } = useNodes();
const localNodeId = useMemo(() => nodes.find((node) => node.type === "local")?.id, [nodes]);
const effectiveSelectedNodeId = selectedNodeId ?? localNodeId ?? null;
const selectedNode = nodes.find((node) => node.id === effectiveSelectedNodeId) ?? null;
const shouldRenderNodeSelector = nodes.length > 1;
const activeNodeName = selectedNode?.name ?? t("systemStats.localNodeFallback", "Local node");
const formatNodeOptionLabel = useCallback((node: (typeof nodes)[number]) => {
const suffixes = [];
if (node.type === "local") {
suffixes.push(t("systemStats.thisNodeSuffix", "this node"));
}
if (node.status && node.status !== "online") {
suffixes.push(t("systemStats.nodeStatusSuffix", "{{status}}", { status: node.status }));
}
return suffixes.length > 0 ? `${node.name} (${suffixes.join(" · ")})` : node.name;
}, [nodes, t]);
/*
FNXC:CommandCenter 2026-06-21-00:00:
The System area node selector must reuse useNodes, default to local telemetry, hide when no remote choice exists, fetch remote telemetry through fetchNodeSystemStats, and clear rolling samples whenever the selected host changes so CPU, memory, heap, workload, and Vitest controls never mix data across nodes.
*/
const loadStats = useCallback(async (options?: { preserveKillResult?: boolean }) => { const loadStats = useCallback(async (options?: { preserveKillResult?: boolean }) => {
setLoading(true); setLoading(true);
try { try {
const response = await fetchSystemStats(projectId); const response = effectiveSelectedNodeId && effectiveSelectedNodeId !== localNodeId
? await fetchNodeSystemStats(effectiveSelectedNodeId, projectId)
: await fetchSystemStats(projectId);
setStats(response); setStats(response);
setSamples((prev) => [...prev, sampleFromStats(response)].slice(-MAX_SYSTEM_SAMPLES)); setSamples((prev) => [...prev, sampleFromStats(response)].slice(-MAX_SYSTEM_SAMPLES));
setError(null); setError(null);
@@ -142,7 +168,7 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [projectId, t]); }, [effectiveSelectedNodeId, localNodeId, projectId, t]);
useEffect(() => { useEffect(() => {
void loadStats(); void loadStats();
@@ -154,6 +180,17 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {
}; };
}, [loadStats]); }, [loadStats]);
useEffect(() => {
setSelectedNodeId((current) => (current && nodes.some((node) => node.id === current) ? current : null));
}, [nodes]);
useEffect(() => {
setSamples([]);
setError(null);
setKillResult(null);
setConfirmKill(false);
}, [effectiveSelectedNodeId]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const loadSettings = async () => { const loadSettings = async () => {
@@ -206,7 +243,9 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {
setIsKilling(true); setIsKilling(true);
try { try {
const result = await killVitestProcesses(projectId); const result = effectiveSelectedNodeId && effectiveSelectedNodeId !== localNodeId
? await killVitestProcesses(projectId, effectiveSelectedNodeId, localNodeId)
: await killVitestProcesses(projectId);
setKillResult(result); setKillResult(result);
setConfirmKill(false); setConfirmKill(false);
await loadStats({ preserveKillResult: true }); await loadStats({ preserveKillResult: true });
@@ -215,7 +254,7 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {
} finally { } finally {
setIsKilling(false); setIsKilling(false);
} }
}, [confirmKill, isKilling, loadStats, projectId, t]); }, [confirmKill, effectiveSelectedNodeId, isKilling, loadStats, localNodeId, projectId, t]);
const system = stats?.systemStats; const system = stats?.systemStats;
const taskStats = stats?.taskStats; const taskStats = stats?.taskStats;
@@ -298,6 +337,31 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {
<div className="cc-area-section-header"> <div className="cc-area-section-header">
<h3 className="cc-area-section-title">{t("commandCenter.system.healthTitle", "Live system health")}</h3> <h3 className="cc-area-section-title">{t("commandCenter.system.healthTitle", "Live system health")}</h3>
<div className="cc-system-refresh" aria-live="polite"> <div className="cc-system-refresh" aria-live="polite">
{shouldRenderNodeSelector ? (
<label className="cc-system-node-selector" htmlFor="cc-system-node-select">
<span>{t("systemStats.nodeSelectorLabel", "Node")}</span>
<select
id="cc-system-node-select"
className="input"
data-testid="cc-system-node-select"
aria-label={t("systemStats.nodeSelectorAriaLabel", "Select system stats node")}
value={effectiveSelectedNodeId ?? ""}
onChange={(event) => {
setSamples([]);
setKillResult(null);
setConfirmKill(false);
setSelectedNodeId(event.target.value || null);
}}
>
{nodes.map((node) => (
<option key={node.id} value={node.id}>
{formatNodeOptionLabel(node)}
</option>
))}
</select>
</label>
) : null}
<span>{t("systemStats.viewingNode", "Viewing {{node}}", { node: activeNodeName })}</span>
<span>{t("systemStats.autoRefresh", "Auto-refresh · 5s")}</span> <span>{t("systemStats.autoRefresh", "Auto-refresh · 5s")}</span>
<span>{refreshLabel}</span> <span>{refreshLabel}</span>
<button <button

View File

@@ -9,6 +9,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { createHmac } from "node:crypto"; import { createHmac } from "node:crypto";
import { ApiError } from "../api-error.js";
import { createApiRoutes } from "../routes.js"; import { createApiRoutes } from "../routes.js";
import { import {
getProjectIdFromRequest as getProjectIdFromRouteRequest, getProjectIdFromRequest as getProjectIdFromRouteRequest,
@@ -41,6 +42,7 @@ import { __setAgentReflectionServiceForTests } from "../routes/register-agent-re
// Mock @fusion/core for gh CLI auth checks // Mock @fusion/core for gh CLI auth checks
const mockCentralListProjects = vi.fn().mockResolvedValue([]); const mockCentralListProjects = vi.fn().mockResolvedValue([]);
const mockCentralGetNode = vi.fn();
const mockCentralInit = vi.fn().mockResolvedValue(undefined); const mockCentralInit = vi.fn().mockResolvedValue(undefined);
const mockCentralClose = vi.fn().mockResolvedValue(undefined); const mockCentralClose = vi.fn().mockResolvedValue(undefined);
const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined); const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
@@ -51,6 +53,7 @@ const {
mockExecFile, mockExecFile,
mockReloadExemptTools, mockReloadExemptTools,
mockGetExemptToolNames, mockGetExemptToolNames,
mockFetchFromRemoteNode,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
mockPerformUpdateCheck: vi.fn(), mockPerformUpdateCheck: vi.fn(),
mockClearUpdateCheckCache: vi.fn(), mockClearUpdateCheckCache: vi.fn(),
@@ -58,6 +61,7 @@ const {
mockExecFile: vi.fn(), mockExecFile: vi.fn(),
mockReloadExemptTools: vi.fn(), mockReloadExemptTools: vi.fn(),
mockGetExemptToolNames: vi.fn().mockReturnValue(["read", "find"]), mockGetExemptToolNames: vi.fn().mockReturnValue(["read", "find"]),
mockFetchFromRemoteNode: vi.fn(),
})); }));
vi.mock("../update-check.js", async () => { vi.mock("../update-check.js", async () => {
@@ -69,6 +73,14 @@ vi.mock("../update-check.js", async () => {
}; };
}); });
vi.mock("../routes/register-settings-sync-helpers.js", async () => {
const actual = await vi.importActual<typeof import("../routes/register-settings-sync-helpers.js")>("../routes/register-settings-sync-helpers.js");
return {
...actual,
fetchFromRemoteNode: mockFetchFromRemoteNode,
};
});
vi.mock("node:child_process", async () => { vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process"); const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
mockExecSync.mockImplementation(((...args: Parameters<typeof actual.execSync>) => actual.execSync(...args)) as typeof actual.execSync); mockExecSync.mockImplementation(((...args: Parameters<typeof actual.execSync>) => actual.execSync(...args)) as typeof actual.execSync);
@@ -112,6 +124,7 @@ vi.mock("@fusion/core", async (importOriginal) => {
init: mockCentralInit, init: mockCentralInit,
close: mockCentralClose, close: mockCentralClose,
listProjects: mockCentralListProjects, listProjects: mockCentralListProjects,
getNode: mockCentralGetNode,
reconcileProjectStatuses: mockCentralReconcileProjectStatuses, reconcileProjectStatuses: mockCentralReconcileProjectStatuses,
}; }), }; }),
}); });
@@ -566,6 +579,115 @@ describe("GET /api/system-stats", () => {
}); });
expect(res.body.vitestLastAutoKillAt).toBeNull(); expect(res.body.vitestLastAutoKillAt).toBeNull();
}); });
describe("GET /api/nodes/:id/system-stats", () => {
const remoteStatsPayload = {
systemStats: {
rss: 123,
heapUsed: 45,
heapTotal: 67,
heapLimit: 89,
external: 10,
arrayBuffers: 11,
cpuPercent: 12.5,
loadAvg: [1, 2, 3],
cpuCount: 8,
systemTotalMem: 1600,
systemFreeMem: 400,
pid: 999,
nodeVersion: "v26.0.0",
platform: "darwin/arm64",
},
taskStats: {
total: 2,
byColumn: { triage: 0, todo: 1, "in-progress": 1, "in-review": 0, done: 0, archived: 0 },
active: 1,
agents: { idle: 1, active: 0, running: 1, error: 0 },
},
vitestProcessCount: 0,
vitestLastAutoKillAt: null,
};
beforeEach(() => {
mockCentralGetNode.mockReset();
mockCentralInit.mockClear();
mockCentralClose.mockClear();
mockFetchFromRemoteNode.mockReset();
vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
vi.spyOn(AgentStore.prototype, "listAgents").mockResolvedValue([]);
});
it("returns local stats for a local node id through the shared builder", async () => {
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([{ id: "FN-local", column: "in-progress" }]),
getFusionDir: vi.fn().mockReturnValue("/fake/default"),
});
mockCentralGetNode.mockResolvedValue({ id: "node-local", name: "local", type: "local", status: "online" });
const directRes = await GET(buildApp(store), "/api/system-stats");
const nodeRes = await GET(buildApp(store), "/api/nodes/node-local/system-stats");
expect(directRes.status).toBe(200);
expect(nodeRes.status).toBe(200);
expect(nodeRes.body).toEqual(
expect.objectContaining({
systemStats: expect.objectContaining({
rss: expect.any(Number),
heapUsed: expect.any(Number),
pid: expect.any(Number),
}),
taskStats: {
total: 1,
byColumn: { triage: 0, todo: 0, "in-progress": 1, "in-review": 0, done: 0, archived: 0 },
active: 1,
agents: { idle: 0, active: 0, running: 0, error: 0 },
},
vitestProcessCount: expect.any(Number),
vitestLastAutoKillAt: null,
}),
);
expect(Object.keys(nodeRes.body)).toEqual(Object.keys(directRes.body));
expect(mockFetchFromRemoteNode).not.toHaveBeenCalled();
expect(mockCentralClose).toHaveBeenCalled();
});
it("proxies remote node stats through the authenticated remote helper", async () => {
const remoteNode = { id: "node-remote", name: "Remote", type: "remote", url: "http://remote.test", apiKey: "secret", status: "online" };
mockCentralGetNode.mockResolvedValue(remoteNode);
mockFetchFromRemoteNode.mockResolvedValue(remoteStatsPayload);
const res = await GET(buildApp(createMockStore()), "/api/nodes/node-remote/system-stats");
expect(res.status).toBe(200);
expect(res.body).toEqual(remoteStatsPayload);
expect(mockFetchFromRemoteNode).toHaveBeenCalledWith(remoteNode, "/api/system-stats");
expect(mockCentralClose).toHaveBeenCalled();
});
it("returns 404 when the selected node is unknown", async () => {
mockCentralGetNode.mockResolvedValue(null);
const res = await GET(buildApp(createMockStore()), "/api/nodes/missing/system-stats");
expect(res.status).toBe(404);
expect(res.body.error).toContain("Node not found");
expect(mockFetchFromRemoteNode).not.toHaveBeenCalled();
expect(mockCentralClose).toHaveBeenCalled();
});
it("surfaces missing remote URL/api-key configuration as a non-500 route error", async () => {
const remoteNodeWithoutUrl = { id: "node-docker", name: "Managed Docker", type: "docker-managed", status: "offline" };
mockCentralGetNode.mockResolvedValue(remoteNodeWithoutUrl);
mockFetchFromRemoteNode.mockRejectedValue(new ApiError(400, "Node has no URL configured"));
const res = await GET(buildApp(createMockStore()), "/api/nodes/node-docker/system-stats");
expect(res.status).toBe(400);
expect(res.body.error).toContain("Node has no URL configured");
expect(mockFetchFromRemoteNode).toHaveBeenCalledWith(remoteNodeWithoutUrl, "/api/system-stats");
expect(mockCentralClose).toHaveBeenCalled();
});
});
}); });
describe("POST /api/kill-vitest", () => { describe("POST /api/kill-vitest", () => {

View File

@@ -54,6 +54,7 @@ import {
unauthorized, unauthorized,
} from "./api-error.js"; } from "./api-error.js";
import { createPluginRouter, resolvePluginManifest } from "./plugin-routes.js"; import { createPluginRouter, resolvePluginManifest } from "./plugin-routes.js";
import { fetchFromRemoteNode } from "./routes/register-settings-sync-helpers.js";
import { hermesRuntimeMetadata } from "@fusion-plugin-examples/hermes-runtime"; import { hermesRuntimeMetadata } from "@fusion-plugin-examples/hermes-runtime";
import { openclawRuntimeMetadata } from "@fusion-plugin-examples/openclaw-runtime"; import { openclawRuntimeMetadata } from "@fusion-plugin-examples/openclaw-runtime";
@@ -1517,93 +1518,132 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return findVitestProcessIds(); return findVitestProcessIds();
}; };
const collectSystemStatsResponse = async (req: Request) => {
const mem = process.memoryUsage();
const heapStats = v8.getHeapStatistics();
const load = os.loadavg();
const vitestProcessIds = await getVitestProcessIds();
const cpuPercent = getAppCpuPercent();
let totalTasks = 0;
let activeTasks = 0;
const byColumn: Record<string, number> = {
triage: 0,
todo: 0,
"in-progress": 0,
"in-review": 0,
done: 0,
archived: 0,
};
const agentCounts = { idle: 0, active: 0, running: 0, error: 0 };
let vitestLastAutoKillAt: string | null = null;
try {
const { store: scopedStore } = await getProjectContext(req);
const globalSettingsStore = scopedStore.getGlobalSettingsStore?.();
if (globalSettingsStore?.getSettings) {
const globalSettings = await globalSettingsStore.getSettings();
const candidate = (globalSettings as Record<string, unknown>).vitestLastAutoKillAt;
if (typeof candidate === "string" && candidate.length > 0) {
vitestLastAutoKillAt = candidate;
}
}
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
totalTasks = tasks.length;
for (const task of tasks) {
byColumn[task.column] = (byColumn[task.column] ?? 0) + 1;
if (task.column === "in-progress" || task.column === "in-review") {
activeTasks += 1;
}
}
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agents = await agentStore.listAgents();
for (const agent of agents) {
const state = agent.state as keyof typeof agentCounts;
if (state in agentCounts) {
agentCounts[state] += 1;
}
}
} catch {
// System stats should still be available even when project resolution/scoped store fails.
}
return {
systemStats: {
rss: mem.rss,
heapUsed: mem.heapUsed,
heapTotal: mem.heapTotal,
heapLimit: heapStats.heap_size_limit,
external: mem.external,
arrayBuffers: mem.arrayBuffers,
cpuPercent,
loadAvg: [load[0] ?? 0, load[1] ?? 0, load[2] ?? 0],
cpuCount: os.cpus().length,
systemTotalMem: os.totalmem(),
systemFreeMem: os.freemem(),
pid: process.pid,
nodeVersion: process.version,
platform: `${process.platform}/${process.arch}`,
},
taskStats: {
total: totalTasks,
byColumn,
active: activeTasks,
agents: agentCounts,
},
vitestProcessCount: vitestProcessIds.length,
vitestLastAutoKillAt,
};
};
/** /**
* GET /api/system-stats * GET /api/system-stats
* Returns process/system metrics plus task and agent aggregates. * Returns process/system metrics plus task and agent aggregates.
*/ */
router.get("/system-stats", async (req, res) => { router.get("/system-stats", async (req, res) => {
try { try {
const mem = process.memoryUsage(); res.json(await collectSystemStatsResponse(req));
const heapStats = v8.getHeapStatistics(); } catch (err: unknown) {
const load = os.loadavg(); if (err instanceof ApiError) {
const vitestProcessIds = await getVitestProcessIds(); throw err;
const cpuPercent = getAppCpuPercent(); }
rethrowAsApiError(err);
let totalTasks = 0; }
let activeTasks = 0; });
const byColumn: Record<string, number> = {
triage: 0,
todo: 0,
"in-progress": 0,
"in-review": 0,
done: 0,
archived: 0,
};
const agentCounts = { idle: 0, active: 0, running: 0, error: 0 };
let vitestLastAutoKillAt: string | null = null;
/*
FNXC:CommandCenter 2026-06-21-00:00:
The Command Center System area can view per-node stats by defaulting to this local process and proxying remote selections to the node's own /api/system-stats endpoint through the existing authenticated remote-node helper.
Keep this route in routes.ts, rather than a domain registrar, because it intentionally reuses the local getAppCpuPercent/getVitestProcessIds closures and the shared local stats builder.
*/
router.get("/nodes/:id/system-stats", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
let node: Awaited<ReturnType<typeof central.getNode>>;
await central.init();
try { try {
const { store: scopedStore } = await getProjectContext(req); node = await central.getNode(req.params.id);
} finally {
const globalSettingsStore = scopedStore.getGlobalSettingsStore?.(); await central.close();
if (globalSettingsStore?.getSettings) {
const globalSettings = await globalSettingsStore.getSettings();
const candidate = (globalSettings as Record<string, unknown>).vitestLastAutoKillAt;
if (typeof candidate === "string" && candidate.length > 0) {
vitestLastAutoKillAt = candidate;
}
}
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
totalTasks = tasks.length;
for (const task of tasks) {
byColumn[task.column] = (byColumn[task.column] ?? 0) + 1;
if (task.column === "in-progress" || task.column === "in-review") {
activeTasks += 1;
}
}
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agents = await agentStore.listAgents();
for (const agent of agents) {
const state = agent.state as keyof typeof agentCounts;
if (state in agentCounts) {
agentCounts[state] += 1;
}
}
} catch {
// System stats should still be available even when project resolution/scoped store fails.
} }
res.json({ if (!node) {
systemStats: { throw notFound("Node not found");
rss: mem.rss, }
heapUsed: mem.heapUsed,
heapTotal: mem.heapTotal, if (node.type === "local") {
heapLimit: heapStats.heap_size_limit, res.json(await collectSystemStatsResponse(req));
external: mem.external, return;
arrayBuffers: mem.arrayBuffers, }
cpuPercent,
loadAvg: [load[0] ?? 0, load[1] ?? 0, load[2] ?? 0], res.json(await fetchFromRemoteNode(node, "/api/system-stats"));
cpuCount: os.cpus().length,
systemTotalMem: os.totalmem(),
systemFreeMem: os.freemem(),
pid: process.pid,
nodeVersion: process.version,
platform: `${process.platform}/${process.arch}`,
},
taskStats: {
total: totalTasks,
byColumn,
active: activeTasks,
agents: agentCounts,
},
vitestProcessCount: vitestProcessIds.length,
vitestLastAutoKillAt,
});
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
throw err; throw err;

View File

@@ -6962,6 +6962,10 @@
"resultSuccess": "Success" "resultSuccess": "Success"
}, },
"systemStats": { "systemStats": {
"agentActive": "active",
"agentError": "error",
"agentIdle": "idle",
"agentRunning": "running",
"autoKillLabel": "Auto-kill vitest on memory pressure", "autoKillLabel": "Auto-kill vitest on memory pressure",
"autoRefresh": "Auto-refresh · 5s", "autoRefresh": "Auto-refresh · 5s",
"confirmKill": "Confirm Kill?", "confirmKill": "Confirm Kill?",
@@ -6980,6 +6984,10 @@
"killThresholdSliderAriaLabel": "Kill threshold slider (%)", "killThresholdSliderAriaLabel": "Kill threshold slider (%)",
"killVitest": "Kill Vitest Processes", "killVitest": "Kill Vitest Processes",
"lastAutoKill": "Last auto-kill: {{time}}", "lastAutoKill": "Last auto-kill: {{time}}",
"localNodeFallback": "Local node",
"nodeSelectorAriaLabel": "Select system stats node",
"nodeSelectorLabel": "Node",
"nodeStatusSuffix": "{{status}}",
"notYet": "Not yet", "notYet": "Not yet",
"refreshAriaLabel": "Refresh system stats", "refreshAriaLabel": "Refresh system stats",
"refreshTitle": "Refresh", "refreshTitle": "Refresh",
@@ -7003,7 +7011,9 @@
"sectionTasks": "Tasks", "sectionTasks": "Tasks",
"sectionTasksAriaLabel": "Task stats", "sectionTasksAriaLabel": "Task stats",
"sectionVitest": "Vitest Controls", "sectionVitest": "Vitest Controls",
"thisNodeSuffix": "this node",
"updatedAt": "Updated {{time}}", "updatedAt": "Updated {{time}}",
"viewingNode": "Viewing {{node}}",
"vitestProcesses": "Vitest Processes", "vitestProcesses": "Vitest Processes",
"waitingFirstUpdate": "Waiting for first update" "waitingFirstUpdate": "Waiting for first update"
}, },

View File

@@ -6959,6 +6959,10 @@
"resultSuccess": "Éxito" "resultSuccess": "Éxito"
}, },
"systemStats": { "systemStats": {
"agentActive": "active",
"agentError": "error",
"agentIdle": "idle",
"agentRunning": "running",
"autoKillLabel": "Terminar vitest automáticamente bajo presión de memoria", "autoKillLabel": "Terminar vitest automáticamente bajo presión de memoria",
"autoRefresh": "Actualización automática · 5s", "autoRefresh": "Actualización automática · 5s",
"confirmKill": "¿Confirmar cierre?", "confirmKill": "¿Confirmar cierre?",
@@ -6977,6 +6981,10 @@
"killThresholdSliderAriaLabel": "Control deslizante del umbral de cierre (%)", "killThresholdSliderAriaLabel": "Control deslizante del umbral de cierre (%)",
"killVitest": "Terminar procesos de vitest", "killVitest": "Terminar procesos de vitest",
"lastAutoKill": "Último cierre automático: {{time}}", "lastAutoKill": "Último cierre automático: {{time}}",
"localNodeFallback": "Local node",
"nodeSelectorAriaLabel": "Select system stats node",
"nodeSelectorLabel": "Node",
"nodeStatusSuffix": "{{status}}",
"notYet": "Aún no", "notYet": "Aún no",
"refreshAriaLabel": "Actualizar estadísticas del sistema", "refreshAriaLabel": "Actualizar estadísticas del sistema",
"refreshTitle": "Actualizar", "refreshTitle": "Actualizar",
@@ -7000,7 +7008,9 @@
"sectionTasks": "Tareas", "sectionTasks": "Tareas",
"sectionTasksAriaLabel": "Estadísticas de tareas", "sectionTasksAriaLabel": "Estadísticas de tareas",
"sectionVitest": "Controles de vitest", "sectionVitest": "Controles de vitest",
"thisNodeSuffix": "this node",
"updatedAt": "Actualizado {{time}}", "updatedAt": "Actualizado {{time}}",
"viewingNode": "Viewing {{node}}",
"vitestProcesses": "Procesos de vitest", "vitestProcesses": "Procesos de vitest",
"waitingFirstUpdate": "Esperando la primera actualización" "waitingFirstUpdate": "Esperando la primera actualización"
}, },

View File

@@ -6959,6 +6959,10 @@
"resultSuccess": "Succès" "resultSuccess": "Succès"
}, },
"systemStats": { "systemStats": {
"agentActive": "active",
"agentError": "error",
"agentIdle": "idle",
"agentRunning": "running",
"autoKillLabel": "Arrêt automatique de vitest sous pression mémoire", "autoKillLabel": "Arrêt automatique de vitest sous pression mémoire",
"autoRefresh": "Actualisation auto · 5s", "autoRefresh": "Actualisation auto · 5s",
"confirmKill": "Confirmer l'arrêt ?", "confirmKill": "Confirmer l'arrêt ?",
@@ -6977,6 +6981,10 @@
"killThresholdSliderAriaLabel": "Curseur de seuil d'arrêt (%)", "killThresholdSliderAriaLabel": "Curseur de seuil d'arrêt (%)",
"killVitest": "Arrêter les processus vitest", "killVitest": "Arrêter les processus vitest",
"lastAutoKill": "Dernier arrêt automatique : {{time}}", "lastAutoKill": "Dernier arrêt automatique : {{time}}",
"localNodeFallback": "Local node",
"nodeSelectorAriaLabel": "Select system stats node",
"nodeSelectorLabel": "Node",
"nodeStatusSuffix": "{{status}}",
"notYet": "Pas encore", "notYet": "Pas encore",
"refreshAriaLabel": "Actualiser les statistiques système", "refreshAriaLabel": "Actualiser les statistiques système",
"refreshTitle": "Actualiser", "refreshTitle": "Actualiser",
@@ -7000,7 +7008,9 @@
"sectionTasks": "Tâches", "sectionTasks": "Tâches",
"sectionTasksAriaLabel": "Statistiques des tâches", "sectionTasksAriaLabel": "Statistiques des tâches",
"sectionVitest": "Contrôles vitest", "sectionVitest": "Contrôles vitest",
"thisNodeSuffix": "this node",
"updatedAt": "Mis à jour {{time}}", "updatedAt": "Mis à jour {{time}}",
"viewingNode": "Viewing {{node}}",
"vitestProcesses": "Processus vitest", "vitestProcesses": "Processus vitest",
"waitingFirstUpdate": "En attente de la première mise à jour" "waitingFirstUpdate": "En attente de la première mise à jour"
}, },

View File

@@ -6959,6 +6959,10 @@
"resultSuccess": "성공" "resultSuccess": "성공"
}, },
"systemStats": { "systemStats": {
"agentActive": "active",
"agentError": "error",
"agentIdle": "idle",
"agentRunning": "running",
"autoKillLabel": "메모리 압박 시 vitest 자동 종료", "autoKillLabel": "메모리 압박 시 vitest 자동 종료",
"autoRefresh": "자동 새로 고침 · 5초", "autoRefresh": "자동 새로 고침 · 5초",
"confirmKill": "종료를 확인하시겠습니까?", "confirmKill": "종료를 확인하시겠습니까?",
@@ -6977,6 +6981,10 @@
"killThresholdSliderAriaLabel": "종료 임계값 슬라이더 (%)", "killThresholdSliderAriaLabel": "종료 임계값 슬라이더 (%)",
"killVitest": "Vitest 프로세스 종료", "killVitest": "Vitest 프로세스 종료",
"lastAutoKill": "마지막 자동 종료: {{time}}", "lastAutoKill": "마지막 자동 종료: {{time}}",
"localNodeFallback": "Local node",
"nodeSelectorAriaLabel": "Select system stats node",
"nodeSelectorLabel": "Node",
"nodeStatusSuffix": "{{status}}",
"notYet": "아직 없음", "notYet": "아직 없음",
"refreshAriaLabel": "시스템 통계 새로 고침", "refreshAriaLabel": "시스템 통계 새로 고침",
"refreshTitle": "새로 고침", "refreshTitle": "새로 고침",
@@ -7000,7 +7008,9 @@
"sectionTasks": "작업", "sectionTasks": "작업",
"sectionTasksAriaLabel": "작업 통계", "sectionTasksAriaLabel": "작업 통계",
"sectionVitest": "Vitest 컨트롤", "sectionVitest": "Vitest 컨트롤",
"thisNodeSuffix": "this node",
"updatedAt": "{{time}} 업데이트됨", "updatedAt": "{{time}} 업데이트됨",
"viewingNode": "Viewing {{node}}",
"vitestProcesses": "Vitest 프로세스", "vitestProcesses": "Vitest 프로세스",
"waitingFirstUpdate": "첫 업데이트 대기 중" "waitingFirstUpdate": "첫 업데이트 대기 중"
}, },

View File

@@ -6959,6 +6959,10 @@
"resultSuccess": "成功" "resultSuccess": "成功"
}, },
"systemStats": { "systemStats": {
"agentActive": "active",
"agentError": "error",
"agentIdle": "idle",
"agentRunning": "running",
"autoKillLabel": "内存压力时自动终止 vitest", "autoKillLabel": "内存压力时自动终止 vitest",
"autoRefresh": "自动刷新 · 5s", "autoRefresh": "自动刷新 · 5s",
"confirmKill": "确认终止?", "confirmKill": "确认终止?",
@@ -6977,6 +6981,10 @@
"killThresholdSliderAriaLabel": "终止阈值滑块 (%)", "killThresholdSliderAriaLabel": "终止阈值滑块 (%)",
"killVitest": "终止 Vitest 进程", "killVitest": "终止 Vitest 进程",
"lastAutoKill": "上次自动终止:{{time}}", "lastAutoKill": "上次自动终止:{{time}}",
"localNodeFallback": "Local node",
"nodeSelectorAriaLabel": "Select system stats node",
"nodeSelectorLabel": "Node",
"nodeStatusSuffix": "{{status}}",
"notYet": "尚未", "notYet": "尚未",
"refreshAriaLabel": "刷新系统统计", "refreshAriaLabel": "刷新系统统计",
"refreshTitle": "刷新", "refreshTitle": "刷新",
@@ -7000,7 +7008,9 @@
"sectionTasks": "任务", "sectionTasks": "任务",
"sectionTasksAriaLabel": "任务统计", "sectionTasksAriaLabel": "任务统计",
"sectionVitest": "Vitest 控制", "sectionVitest": "Vitest 控制",
"thisNodeSuffix": "this node",
"updatedAt": "已更新 {{time}}", "updatedAt": "已更新 {{time}}",
"viewingNode": "Viewing {{node}}",
"vitestProcesses": "Vitest 进程", "vitestProcesses": "Vitest 进程",
"waitingFirstUpdate": "等待首次更新" "waitingFirstUpdate": "等待首次更新"
}, },

View File

@@ -6959,6 +6959,10 @@
"resultSuccess": "成功" "resultSuccess": "成功"
}, },
"systemStats": { "systemStats": {
"agentActive": "active",
"agentError": "error",
"agentIdle": "idle",
"agentRunning": "running",
"autoKillLabel": "記憶體壓力時自動終止 vitest", "autoKillLabel": "記憶體壓力時自動終止 vitest",
"autoRefresh": "自動重新整理 · 5s", "autoRefresh": "自動重新整理 · 5s",
"confirmKill": "確認終止?", "confirmKill": "確認終止?",
@@ -6977,6 +6981,10 @@
"killThresholdSliderAriaLabel": "終止閾值滑桿 (%)", "killThresholdSliderAriaLabel": "終止閾值滑桿 (%)",
"killVitest": "終止 Vitest 程序", "killVitest": "終止 Vitest 程序",
"lastAutoKill": "上次自動終止:{{time}}", "lastAutoKill": "上次自動終止:{{time}}",
"localNodeFallback": "Local node",
"nodeSelectorAriaLabel": "Select system stats node",
"nodeSelectorLabel": "Node",
"nodeStatusSuffix": "{{status}}",
"notYet": "尚未", "notYet": "尚未",
"refreshAriaLabel": "重新整理系統統計", "refreshAriaLabel": "重新整理系統統計",
"refreshTitle": "重新整理", "refreshTitle": "重新整理",
@@ -7000,7 +7008,9 @@
"sectionTasks": "任務", "sectionTasks": "任務",
"sectionTasksAriaLabel": "任務統計", "sectionTasksAriaLabel": "任務統計",
"sectionVitest": "Vitest 控制", "sectionVitest": "Vitest 控制",
"thisNodeSuffix": "this node",
"updatedAt": "已更新 {{time}}", "updatedAt": "已更新 {{time}}",
"viewingNode": "Viewing {{node}}",
"vitestProcesses": "Vitest 程序", "vitestProcesses": "Vitest 程序",
"waitingFirstUpdate": "等待首次更新" "waitingFirstUpdate": "等待首次更新"
}, },