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:
5
.changeset/fuzzy-nodes-watch.md
Normal file
5
.changeset/fuzzy-nodes-watch.md
Normal 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.
|
||||
@@ -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. -->
|
||||
- **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.
|
||||
- **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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Reliability View
|
||||
|
||||
@@ -7213,13 +7213,23 @@ export interface GithubSourceIssueClosedAtBackfillResult {
|
||||
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> {
|
||||
return api<SystemStatsResponse>(withProjectId("/system-stats", projectId));
|
||||
}
|
||||
|
||||
export function killVitestProcesses(projectId?: string): Promise<KillVitestResponse> {
|
||||
return api<KillVitestResponse>(withProjectId("/kill-vitest", projectId), {
|
||||
export function fetchNodeSystemStats(nodeId: string, projectId?: string): Promise<SystemStatsResponse> {
|
||||
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",
|
||||
nodeId,
|
||||
localNodeId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ vi.mock("../../../hooks/useAppSettings", () => ({
|
||||
|
||||
vi.mock("../../../api", () => ({
|
||||
fetchSystemStats: () => Promise.resolve(systemStatsFixture()),
|
||||
fetchNodeSystemStats: () => Promise.resolve(systemStatsFixture()),
|
||||
fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }),
|
||||
fetchNodes: () => Promise.resolve([]),
|
||||
killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }),
|
||||
updateGlobalSettings: () => Promise.resolve({}),
|
||||
}));
|
||||
|
||||
@@ -17,7 +17,9 @@ This test renders the real useAppSettings hook rather than mocking it, so the ..
|
||||
*/
|
||||
vi.mock("../../../api", () => ({
|
||||
fetchSystemStats: () => Promise.resolve(systemStatsFixture()),
|
||||
fetchNodeSystemStats: () => Promise.resolve(systemStatsFixture()),
|
||||
fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }),
|
||||
fetchNodes: () => Promise.resolve([]),
|
||||
fetchConfig: vi.fn().mockResolvedValue({ maxConcurrent: 2, rootDir: "/" }),
|
||||
fetchSettings: vi.fn().mockResolvedValue({ autoMerge: false, globalPause: false, enginePaused: false }),
|
||||
killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }),
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("../../../hooks/useAppSettings", () => ({
|
||||
|
||||
vi.mock("../../../api", () => ({
|
||||
fetchSystemStats: () => Promise.resolve(systemStatsFixture()),
|
||||
fetchNodeSystemStats: () => Promise.resolve(systemStatsFixture()),
|
||||
fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }),
|
||||
killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }),
|
||||
updateGlobalSettings: () => Promise.resolve({}),
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { SystemStatsArea } from "../areas/SystemStatsArea";
|
||||
|
||||
const mockFetchSystemStats = vi.fn();
|
||||
const mockFetchNodeSystemStats = vi.fn();
|
||||
const mockFetchGlobalSettings = vi.fn();
|
||||
const mockFetchNodes = vi.fn();
|
||||
const mockKillVitestProcesses = vi.fn();
|
||||
const mockUpdateGlobalSettings = vi.fn();
|
||||
|
||||
vi.mock("../../../api", () => ({
|
||||
fetchSystemStats: (...args: unknown[]) => mockFetchSystemStats(...args),
|
||||
fetchNodeSystemStats: (...args: unknown[]) => mockFetchNodeSystemStats(...args),
|
||||
fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args),
|
||||
fetchNodes: (...args: unknown[]) => mockFetchNodes(...args),
|
||||
killVitestProcesses: (...args: unknown[]) => mockKillVitestProcesses(...args),
|
||||
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
|
||||
}));
|
||||
@@ -18,6 +24,16 @@ vi.mock("../../../api", () => ({
|
||||
const gb = 1024 * 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 SystemStatsFixtureOverrides = Partial<Omit<SystemStatsFixture, "systemStats" | "taskStats">> & {
|
||||
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() {
|
||||
return {
|
||||
systemStats: {
|
||||
@@ -89,7 +109,9 @@ describe("SystemStatsArea", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchSystemStats.mockResolvedValue(sampleStats());
|
||||
mockFetchNodeSystemStats.mockResolvedValue(sampleStats());
|
||||
mockFetchGlobalSettings.mockResolvedValue({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 });
|
||||
mockFetchNodes.mockResolvedValue([]);
|
||||
mockKillVitestProcesses.mockResolvedValue({ killed: 2, pids: [111, 222] });
|
||||
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 () => {
|
||||
vi.useFakeTimers();
|
||||
const { unmount } = render(<SystemStatsArea />);
|
||||
|
||||
@@ -16,6 +16,17 @@ The Command Center System area replaces the standalone System Stats modal with g
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
.cc-system-node-selector,
|
||||
.cc-system-vitest-card,
|
||||
.cc-system-toggle-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%;
|
||||
}
|
||||
|
||||
.cc-system-node-selector .input,
|
||||
.cc-system-vitest-card .btn,
|
||||
.cc-system-threshold-controls .input {
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -3,12 +3,14 @@ import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw, ShieldAlert, Skull } from "lucide-react";
|
||||
import {
|
||||
fetchGlobalSettings,
|
||||
fetchNodeSystemStats,
|
||||
fetchSystemStats,
|
||||
killVitestProcesses,
|
||||
updateGlobalSettings,
|
||||
type KillVitestResponse,
|
||||
type SystemStatsResponse,
|
||||
} from "../../../api";
|
||||
import { useNodes } from "../../../hooks/useNodes";
|
||||
import { Bar, type BarDatum } from "../charts/Bar";
|
||||
import { RadialGauge } from "../charts/RadialGauge";
|
||||
import { Sparkline } from "../charts/Sparkline";
|
||||
@@ -125,11 +127,35 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {
|
||||
const [killResult, setKillResult] = useState<KillVitestResponse | null>(null);
|
||||
const [settingsError, setSettingsError] = useState<string | 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 }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchSystemStats(projectId);
|
||||
const response = effectiveSelectedNodeId && effectiveSelectedNodeId !== localNodeId
|
||||
? await fetchNodeSystemStats(effectiveSelectedNodeId, projectId)
|
||||
: await fetchSystemStats(projectId);
|
||||
setStats(response);
|
||||
setSamples((prev) => [...prev, sampleFromStats(response)].slice(-MAX_SYSTEM_SAMPLES));
|
||||
setError(null);
|
||||
@@ -142,7 +168,7 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, t]);
|
||||
}, [effectiveSelectedNodeId, localNodeId, projectId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStats();
|
||||
@@ -154,6 +180,17 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {
|
||||
};
|
||||
}, [loadStats]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedNodeId((current) => (current && nodes.some((node) => node.id === current) ? current : null));
|
||||
}, [nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
setSamples([]);
|
||||
setError(null);
|
||||
setKillResult(null);
|
||||
setConfirmKill(false);
|
||||
}, [effectiveSelectedNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadSettings = async () => {
|
||||
@@ -206,7 +243,9 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {
|
||||
|
||||
setIsKilling(true);
|
||||
try {
|
||||
const result = await killVitestProcesses(projectId);
|
||||
const result = effectiveSelectedNodeId && effectiveSelectedNodeId !== localNodeId
|
||||
? await killVitestProcesses(projectId, effectiveSelectedNodeId, localNodeId)
|
||||
: await killVitestProcesses(projectId);
|
||||
setKillResult(result);
|
||||
setConfirmKill(false);
|
||||
await loadStats({ preserveKillResult: true });
|
||||
@@ -215,7 +254,7 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {
|
||||
} finally {
|
||||
setIsKilling(false);
|
||||
}
|
||||
}, [confirmKill, isKilling, loadStats, projectId, t]);
|
||||
}, [confirmKill, effectiveSelectedNodeId, isKilling, loadStats, localNodeId, projectId, t]);
|
||||
|
||||
const system = stats?.systemStats;
|
||||
const taskStats = stats?.taskStats;
|
||||
@@ -298,6 +337,31 @@ export function SystemStatsArea({ projectId }: { projectId?: string }) {
|
||||
<div className="cc-area-section-header">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.system.healthTitle", "Live system health")}</h3>
|
||||
<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>{refreshLabel}</span>
|
||||
<button
|
||||
|
||||
@@ -9,6 +9,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHmac } from "node:crypto";
|
||||
import { ApiError } from "../api-error.js";
|
||||
import { createApiRoutes } from "../routes.js";
|
||||
import {
|
||||
getProjectIdFromRequest as getProjectIdFromRouteRequest,
|
||||
@@ -41,6 +42,7 @@ import { __setAgentReflectionServiceForTests } from "../routes/register-agent-re
|
||||
|
||||
// Mock @fusion/core for gh CLI auth checks
|
||||
const mockCentralListProjects = vi.fn().mockResolvedValue([]);
|
||||
const mockCentralGetNode = vi.fn();
|
||||
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockCentralClose = vi.fn().mockResolvedValue(undefined);
|
||||
const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -51,6 +53,7 @@ const {
|
||||
mockExecFile,
|
||||
mockReloadExemptTools,
|
||||
mockGetExemptToolNames,
|
||||
mockFetchFromRemoteNode,
|
||||
} = vi.hoisted(() => ({
|
||||
mockPerformUpdateCheck: vi.fn(),
|
||||
mockClearUpdateCheckCache: vi.fn(),
|
||||
@@ -58,6 +61,7 @@ const {
|
||||
mockExecFile: vi.fn(),
|
||||
mockReloadExemptTools: vi.fn(),
|
||||
mockGetExemptToolNames: vi.fn().mockReturnValue(["read", "find"]),
|
||||
mockFetchFromRemoteNode: vi.fn(),
|
||||
}));
|
||||
|
||||
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 () => {
|
||||
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);
|
||||
@@ -112,6 +124,7 @@ vi.mock("@fusion/core", async (importOriginal) => {
|
||||
init: mockCentralInit,
|
||||
close: mockCentralClose,
|
||||
listProjects: mockCentralListProjects,
|
||||
getNode: mockCentralGetNode,
|
||||
reconcileProjectStatuses: mockCentralReconcileProjectStatuses,
|
||||
}; }),
|
||||
});
|
||||
@@ -566,6 +579,115 @@ describe("GET /api/system-stats", () => {
|
||||
});
|
||||
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", () => {
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
unauthorized,
|
||||
} from "./api-error.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 { openclawRuntimeMetadata } from "@fusion-plugin-examples/openclaw-runtime";
|
||||
|
||||
@@ -1517,93 +1518,132 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
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
|
||||
* Returns process/system metrics plus task and agent aggregates.
|
||||
*/
|
||||
router.get("/system-stats", async (req, res) => {
|
||||
try {
|
||||
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;
|
||||
res.json(await collectSystemStatsResponse(req));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
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 {
|
||||
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.
|
||||
node = await central.getNode(req.params.id);
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
|
||||
res.json({
|
||||
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,
|
||||
});
|
||||
if (!node) {
|
||||
throw notFound("Node not found");
|
||||
}
|
||||
|
||||
if (node.type === "local") {
|
||||
res.json(await collectSystemStatsResponse(req));
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(await fetchFromRemoteNode(node, "/api/system-stats"));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
|
||||
@@ -6962,6 +6962,10 @@
|
||||
"resultSuccess": "Success"
|
||||
},
|
||||
"systemStats": {
|
||||
"agentActive": "active",
|
||||
"agentError": "error",
|
||||
"agentIdle": "idle",
|
||||
"agentRunning": "running",
|
||||
"autoKillLabel": "Auto-kill vitest on memory pressure",
|
||||
"autoRefresh": "Auto-refresh · 5s",
|
||||
"confirmKill": "Confirm Kill?",
|
||||
@@ -6980,6 +6984,10 @@
|
||||
"killThresholdSliderAriaLabel": "Kill threshold slider (%)",
|
||||
"killVitest": "Kill Vitest Processes",
|
||||
"lastAutoKill": "Last auto-kill: {{time}}",
|
||||
"localNodeFallback": "Local node",
|
||||
"nodeSelectorAriaLabel": "Select system stats node",
|
||||
"nodeSelectorLabel": "Node",
|
||||
"nodeStatusSuffix": "{{status}}",
|
||||
"notYet": "Not yet",
|
||||
"refreshAriaLabel": "Refresh system stats",
|
||||
"refreshTitle": "Refresh",
|
||||
@@ -7003,7 +7011,9 @@
|
||||
"sectionTasks": "Tasks",
|
||||
"sectionTasksAriaLabel": "Task stats",
|
||||
"sectionVitest": "Vitest Controls",
|
||||
"thisNodeSuffix": "this node",
|
||||
"updatedAt": "Updated {{time}}",
|
||||
"viewingNode": "Viewing {{node}}",
|
||||
"vitestProcesses": "Vitest Processes",
|
||||
"waitingFirstUpdate": "Waiting for first update"
|
||||
},
|
||||
|
||||
@@ -6959,6 +6959,10 @@
|
||||
"resultSuccess": "Éxito"
|
||||
},
|
||||
"systemStats": {
|
||||
"agentActive": "active",
|
||||
"agentError": "error",
|
||||
"agentIdle": "idle",
|
||||
"agentRunning": "running",
|
||||
"autoKillLabel": "Terminar vitest automáticamente bajo presión de memoria",
|
||||
"autoRefresh": "Actualización automática · 5s",
|
||||
"confirmKill": "¿Confirmar cierre?",
|
||||
@@ -6977,6 +6981,10 @@
|
||||
"killThresholdSliderAriaLabel": "Control deslizante del umbral de cierre (%)",
|
||||
"killVitest": "Terminar procesos de vitest",
|
||||
"lastAutoKill": "Último cierre automático: {{time}}",
|
||||
"localNodeFallback": "Local node",
|
||||
"nodeSelectorAriaLabel": "Select system stats node",
|
||||
"nodeSelectorLabel": "Node",
|
||||
"nodeStatusSuffix": "{{status}}",
|
||||
"notYet": "Aún no",
|
||||
"refreshAriaLabel": "Actualizar estadísticas del sistema",
|
||||
"refreshTitle": "Actualizar",
|
||||
@@ -7000,7 +7008,9 @@
|
||||
"sectionTasks": "Tareas",
|
||||
"sectionTasksAriaLabel": "Estadísticas de tareas",
|
||||
"sectionVitest": "Controles de vitest",
|
||||
"thisNodeSuffix": "this node",
|
||||
"updatedAt": "Actualizado {{time}}",
|
||||
"viewingNode": "Viewing {{node}}",
|
||||
"vitestProcesses": "Procesos de vitest",
|
||||
"waitingFirstUpdate": "Esperando la primera actualización"
|
||||
},
|
||||
|
||||
@@ -6959,6 +6959,10 @@
|
||||
"resultSuccess": "Succès"
|
||||
},
|
||||
"systemStats": {
|
||||
"agentActive": "active",
|
||||
"agentError": "error",
|
||||
"agentIdle": "idle",
|
||||
"agentRunning": "running",
|
||||
"autoKillLabel": "Arrêt automatique de vitest sous pression mémoire",
|
||||
"autoRefresh": "Actualisation auto · 5s",
|
||||
"confirmKill": "Confirmer l'arrêt ?",
|
||||
@@ -6977,6 +6981,10 @@
|
||||
"killThresholdSliderAriaLabel": "Curseur de seuil d'arrêt (%)",
|
||||
"killVitest": "Arrêter les processus vitest",
|
||||
"lastAutoKill": "Dernier arrêt automatique : {{time}}",
|
||||
"localNodeFallback": "Local node",
|
||||
"nodeSelectorAriaLabel": "Select system stats node",
|
||||
"nodeSelectorLabel": "Node",
|
||||
"nodeStatusSuffix": "{{status}}",
|
||||
"notYet": "Pas encore",
|
||||
"refreshAriaLabel": "Actualiser les statistiques système",
|
||||
"refreshTitle": "Actualiser",
|
||||
@@ -7000,7 +7008,9 @@
|
||||
"sectionTasks": "Tâches",
|
||||
"sectionTasksAriaLabel": "Statistiques des tâches",
|
||||
"sectionVitest": "Contrôles vitest",
|
||||
"thisNodeSuffix": "this node",
|
||||
"updatedAt": "Mis à jour {{time}}",
|
||||
"viewingNode": "Viewing {{node}}",
|
||||
"vitestProcesses": "Processus vitest",
|
||||
"waitingFirstUpdate": "En attente de la première mise à jour"
|
||||
},
|
||||
|
||||
@@ -6959,6 +6959,10 @@
|
||||
"resultSuccess": "성공"
|
||||
},
|
||||
"systemStats": {
|
||||
"agentActive": "active",
|
||||
"agentError": "error",
|
||||
"agentIdle": "idle",
|
||||
"agentRunning": "running",
|
||||
"autoKillLabel": "메모리 압박 시 vitest 자동 종료",
|
||||
"autoRefresh": "자동 새로 고침 · 5초",
|
||||
"confirmKill": "종료를 확인하시겠습니까?",
|
||||
@@ -6977,6 +6981,10 @@
|
||||
"killThresholdSliderAriaLabel": "종료 임계값 슬라이더 (%)",
|
||||
"killVitest": "Vitest 프로세스 종료",
|
||||
"lastAutoKill": "마지막 자동 종료: {{time}}",
|
||||
"localNodeFallback": "Local node",
|
||||
"nodeSelectorAriaLabel": "Select system stats node",
|
||||
"nodeSelectorLabel": "Node",
|
||||
"nodeStatusSuffix": "{{status}}",
|
||||
"notYet": "아직 없음",
|
||||
"refreshAriaLabel": "시스템 통계 새로 고침",
|
||||
"refreshTitle": "새로 고침",
|
||||
@@ -7000,7 +7008,9 @@
|
||||
"sectionTasks": "작업",
|
||||
"sectionTasksAriaLabel": "작업 통계",
|
||||
"sectionVitest": "Vitest 컨트롤",
|
||||
"thisNodeSuffix": "this node",
|
||||
"updatedAt": "{{time}} 업데이트됨",
|
||||
"viewingNode": "Viewing {{node}}",
|
||||
"vitestProcesses": "Vitest 프로세스",
|
||||
"waitingFirstUpdate": "첫 업데이트 대기 중"
|
||||
},
|
||||
|
||||
@@ -6959,6 +6959,10 @@
|
||||
"resultSuccess": "成功"
|
||||
},
|
||||
"systemStats": {
|
||||
"agentActive": "active",
|
||||
"agentError": "error",
|
||||
"agentIdle": "idle",
|
||||
"agentRunning": "running",
|
||||
"autoKillLabel": "内存压力时自动终止 vitest",
|
||||
"autoRefresh": "自动刷新 · 5s",
|
||||
"confirmKill": "确认终止?",
|
||||
@@ -6977,6 +6981,10 @@
|
||||
"killThresholdSliderAriaLabel": "终止阈值滑块 (%)",
|
||||
"killVitest": "终止 Vitest 进程",
|
||||
"lastAutoKill": "上次自动终止:{{time}}",
|
||||
"localNodeFallback": "Local node",
|
||||
"nodeSelectorAriaLabel": "Select system stats node",
|
||||
"nodeSelectorLabel": "Node",
|
||||
"nodeStatusSuffix": "{{status}}",
|
||||
"notYet": "尚未",
|
||||
"refreshAriaLabel": "刷新系统统计",
|
||||
"refreshTitle": "刷新",
|
||||
@@ -7000,7 +7008,9 @@
|
||||
"sectionTasks": "任务",
|
||||
"sectionTasksAriaLabel": "任务统计",
|
||||
"sectionVitest": "Vitest 控制",
|
||||
"thisNodeSuffix": "this node",
|
||||
"updatedAt": "已更新 {{time}}",
|
||||
"viewingNode": "Viewing {{node}}",
|
||||
"vitestProcesses": "Vitest 进程",
|
||||
"waitingFirstUpdate": "等待首次更新"
|
||||
},
|
||||
|
||||
@@ -6959,6 +6959,10 @@
|
||||
"resultSuccess": "成功"
|
||||
},
|
||||
"systemStats": {
|
||||
"agentActive": "active",
|
||||
"agentError": "error",
|
||||
"agentIdle": "idle",
|
||||
"agentRunning": "running",
|
||||
"autoKillLabel": "記憶體壓力時自動終止 vitest",
|
||||
"autoRefresh": "自動重新整理 · 5s",
|
||||
"confirmKill": "確認終止?",
|
||||
@@ -6977,6 +6981,10 @@
|
||||
"killThresholdSliderAriaLabel": "終止閾值滑桿 (%)",
|
||||
"killVitest": "終止 Vitest 程序",
|
||||
"lastAutoKill": "上次自動終止:{{time}}",
|
||||
"localNodeFallback": "Local node",
|
||||
"nodeSelectorAriaLabel": "Select system stats node",
|
||||
"nodeSelectorLabel": "Node",
|
||||
"nodeStatusSuffix": "{{status}}",
|
||||
"notYet": "尚未",
|
||||
"refreshAriaLabel": "重新整理系統統計",
|
||||
"refreshTitle": "重新整理",
|
||||
@@ -7000,7 +7008,9 @@
|
||||
"sectionTasks": "任務",
|
||||
"sectionTasksAriaLabel": "任務統計",
|
||||
"sectionVitest": "Vitest 控制",
|
||||
"thisNodeSuffix": "this node",
|
||||
"updatedAt": "已更新 {{time}}",
|
||||
"viewingNode": "Viewing {{node}}",
|
||||
"vitestProcesses": "Vitest 程序",
|
||||
"waitingFirstUpdate": "等待首次更新"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user