From eb3477aba46eda39e9df443ea5da158128d4c727 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 12:54:36 -0700 Subject: [PATCH] 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 --- .changeset/fuzzy-nodes-watch.md | 5 + docs/dashboard-guide.md | 4 +- packages/dashboard/app/api/legacy.ts | 14 +- .../CommandCenter.mobile-scroll.test.tsx | 2 + .../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 ++++++- .../src/__tests__/routes-system.test.ts | 122 +++++++++++ packages/dashboard/src/routes.ts | 194 +++++++++++------- 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, 564 insertions(+), 85 deletions(-) create mode 100644 .changeset/fuzzy-nodes-watch.md diff --git a/.changeset/fuzzy-nodes-watch.md b/.changeset/fuzzy-nodes-watch.md new file mode 100644 index 0000000000..00a97df076 --- /dev/null +++ b/.changeset/fuzzy-nodes-watch.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 0562f8689a..26b31190b8 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -694,7 +694,7 @@ Features: - **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 diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index fd5ecad101..cef95b7a1b 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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 { return api(withProjectId("/system-stats", projectId)); } -export function killVitestProcesses(projectId?: string): Promise { - return api(withProjectId("/kill-vitest", projectId), { +export function fetchNodeSystemStats(nodeId: string, projectId?: string): Promise { + return api(withProjectId(`/nodes/${encodeURIComponent(nodeId)}/system-stats`, projectId)); +} + +export function killVitestProcesses(projectId?: string, nodeId?: string, localNodeId?: string): Promise { + return proxyApi(withProjectId("/kill-vitest", projectId), { method: "POST", + nodeId, + localNodeId, }); } diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx index 2f729423b4..b6ed3ae51b 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-scroll.test.tsx @@ -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({}), })); diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.tablet-layout.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.tablet-layout.test.tsx index fca5af138a..ba0c6fc8ec 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.tablet-layout.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.tablet-layout.test.tsx @@ -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: [] }), diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx index 641243d4c2..42189095c5 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -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({}), diff --git a/packages/dashboard/app/components/command-center/__tests__/SystemStatsArea.test.tsx b/packages/dashboard/app/components/command-center/__tests__/SystemStatsArea.test.tsx index 8878298c6e..d59daa4bbf 100644 --- a/packages/dashboard/app/components/command-center/__tests__/SystemStatsArea.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/SystemStatsArea.test.tsx @@ -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; type SystemStatsFixtureOverrides = Partial> & { systemStats?: Partial; @@ -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(); + + 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(); + + 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(); + + 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(); + 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(); + 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(); + 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(); + 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(); diff --git a/packages/dashboard/app/components/command-center/areas/SystemStatsArea.css b/packages/dashboard/app/components/command-center/areas/SystemStatsArea.css index 3b06484697..4cec71702b 100644 --- a/packages/dashboard/app/components/command-center/areas/SystemStatsArea.css +++ b/packages/dashboard/app/components/command-center/areas/SystemStatsArea.css @@ -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%; diff --git a/packages/dashboard/app/components/command-center/areas/SystemStatsArea.tsx b/packages/dashboard/app/components/command-center/areas/SystemStatsArea.tsx index 4fcb2d3592..b3cf701b43 100644 --- a/packages/dashboard/app/components/command-center/areas/SystemStatsArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/SystemStatsArea.tsx @@ -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(null); const [settingsError, setSettingsError] = useState(null); const [lastRefreshedAt, setLastRefreshedAt] = useState(null); + const [selectedNodeId, setSelectedNodeId] = useState(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 }) {

{t("commandCenter.system.healthTitle", "Live system health")}

+ {shouldRenderNodeSelector ? ( + + ) : null} + {t("systemStats.viewingNode", "Viewing {{node}}", { node: activeNodeName })} {t("systemStats.autoRefresh", "Auto-refresh · 5s")} {refreshLabel}