diff --git a/.changeset/fn-6657-system-stats-into-command-center.md b/.changeset/fn-6657-system-stats-into-command-center.md new file mode 100644 index 0000000000..5a4af762fd --- /dev/null +++ b/.changeset/fn-6657-system-stats-into-command-center.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Move System Stats into the Command Center as a redesigned graph-rich System area with gauges, trend sparklines, task/agent bars, and relocated Vitest controls; remove the standalone System Stats modal plus its Header and mobile More affordances. diff --git a/docs/architecture.md b/docs/architecture.md index 646ac4f21c..8a5863a9b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -854,7 +854,7 @@ Operator setup + troubleshooting guide: **[Remote Access runbook](./remote-acces Key server capabilities: - REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings -- System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry (including app CPU percentage and host memory rendered as numeric values with visual usage bars in the System Stats modal), task/agent aggregates, and manual vitest process termination +- System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry (including app CPU percentage and host memory rendered as numeric values, radial gauges, and trend sparklines in the Command Center System area), task/agent aggregates, and manual vitest process termination - Remote access APIs (`/api/remote/*`) for provider config, activation, tunnel lifecycle, status, token issuance, authenticated URL generation, and QR payload generation - Operational runbook (prereqs/security/troubleshooting): [`docs/remote-access.md`](./remote-access.md) - `/api/remote/tunnel/start`, `/api/remote/tunnel/stop`, and `/api/remote/tunnel/kill-external` cover tunnel lifecycle and external funnel cleanup. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 59dc288228..f6b7bae41c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -654,7 +654,7 @@ Features: ## Command Center -Command Center is the combined analytics and live-operations surface for a project: it pairs historical usage, cost, and throughput analytics with a live Mission Control panel. +Command Center is the combined analytics and live-operations surface for a project: it pairs historical usage, cost, throughput analytics, live system telemetry, and a live Mission Control panel. Navigation: - Desktop: **Header → More views → Command Center** @@ -672,6 +672,7 @@ Features: - **Ecosystem** shows active model breadth and per-model task activity; unavailable plugin-activation metrics render as unavailable rather than zero. - **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. The area shows filed/fixed/net stat cards, filed-vs-fixed daily sparklines, and a by-repository bar breakdown. - **Signals** shows external signal totals, open/resolved counts, MTTR, and source/severity breakdowns when signal sources are connected. +- **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 trend sparklines, and charts tasks by column plus agents by state with the shared Command Center chart primitives. 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. 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)`. @@ -679,6 +680,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. - GitHub issue analytics is local and additive: empty filed/fixed totals render the GitHub area's empty state; 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, and keeps the Command Center tab panel as the mobile scroll owner. - Signals is best-effort: if the Signals endpoint is absent or no signal source is connected, the Signals area falls back to its empty state and other Command Center metrics remain valid. ## Reliability View diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index bba8367591..5ed975f08a 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1335,11 +1335,6 @@ function AppInner() { pushNav({ type: "modal", close: modalManager.closeGitManager }); }, [modalManager, pushNav]); - const openSystemStatsWithNav = useCallback(() => { - modalManager.openSystemStats(); - pushNav({ type: "modal", close: modalManager.closeSystemStats }); - }, [modalManager, pushNav]); - const openSchedulesWithNav = useCallback(() => { modalManager.openSchedules(); pushNav({ type: "modal", close: modalManager.closeSchedules }); @@ -1973,7 +1968,6 @@ function AppInner() { activePlanningSessionCount={bgPlanningSessions.length} onOpenUsage={openUsageWithNav} onOpenActivityLog={openActivityLogWithNav} - onOpenSystemStats={openSystemStatsWithNav} onOpenMailbox={() => handleTaskViewChange("mailbox")} mailboxUnreadCount={mailboxUnreadCount} mailboxPendingApprovalCount={mailboxPendingApprovalCount} @@ -2181,7 +2175,6 @@ function AppInner() { keyboardOpen={mobileNavKeyboardOpen} onOpenSettings={openSettingsWithNav} onOpenActivityLog={openActivityLogWithNav} - onOpenSystemStats={openSystemStatsWithNav} onOpenMailbox={() => handleTaskViewChange("mailbox")} onOpenNodes={handleOpenNodesWithNav} mailboxUnreadCount={mailboxUnreadCount} diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 24dfb41e8d..c8b0de8c6c 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -17,7 +17,6 @@ import { TodoModal } from "./TodoModal"; import { UsageIndicator } from "./UsageIndicator"; import { ScheduledTasksModal } from "./ScheduledTasksModal"; import { NewTaskModal } from "./NewTaskModal"; -import { SystemStatsModal } from "./SystemStatsModal"; import { ActivityLogModal } from "./ActivityLogModal"; import { GitManagerModal } from "./GitManagerModal"; import { AgentListModal } from "./AgentListModal"; @@ -187,11 +186,6 @@ export function AppModals({ modalManager.closeUsage(); }, [modalManager.closeUsage, removeNav]); - const closeSystemStatsWithNav = useCallback(() => { - removeNav(modalManager.closeSystemStats); - modalManager.closeSystemStats(); - }, [modalManager.closeSystemStats, removeNav]); - const closeSchedulesWithNav = useCallback(() => { removeNav(modalManager.closeSchedules); modalManager.closeSchedules(); @@ -426,12 +420,6 @@ export function AppModals({ anchorRect={modalManager.usageAnchorRect} /> - - {modalManager.schedulesOpen && ( void; onOpenActivityLog?: () => void; - onOpenSystemStats?: () => void; /** Opens the mailbox view */ onOpenMailbox?: () => void; /** Unread message count for badge display */ @@ -140,7 +139,6 @@ export function Header({ activePlanningSessionCount = 0, onOpenUsage, onOpenActivityLog, - onOpenSystemStats, onOpenMailbox, mailboxUnreadCount = 0, mailboxPendingApprovalCount = 0, @@ -1314,13 +1312,6 @@ export function Header({ )} - {/* System Stats button - desktop only */} - {!isCompact && onOpenSystemStats && ( - - )} - {/* Activity Log button - desktop only (moved to overflow on mobile/tablet) */} {!isCompact && onOpenActivityLog && ( - - - - - - - {loading && !stats &&
{t("systemStats.loading", "Loading system stats…")}
} - - {error && !stats && ( -
- {error} -
- )} - - {stats && ( -
-
-

{t("systemStats.sectionProcess", "Process")}

-
- {processRows.map((row) => ( -
-
{row.label}
-
- {row.value} - {row.detail ? {row.detail} : null} -
-
- ))} -
-
- -
-

{t("systemStats.sectionCpu", "CPU & Load")}

-
-
-
{t("systemStats.rowAppCpu", "App CPU")}
-
- {cpuPercentLabel} - {cpuPercentValue === null ? t("systemStats.cpuFirstSamplePending", "First sample pending") : t("systemStats.cpuProcessUsage", "process usage")} -
-
-
-
-
-
-
-
-
{t("systemStats.rowLoadAvg", "Load Avg")}
-
{system?.loadAvg.map((value) => value.toFixed(2)).join(" ") ?? "—"}
-
-
-
{t("systemStats.rowCores", "Cores")}
-
{system?.cpuCount ?? "—"}
-
-
-
{t("systemStats.rowPlatform", "Platform")}
-
{system?.platform ?? "—"}
-
-
-
{t("systemStats.rowNode", "Node")}
-
{system?.nodeVersion ?? "—"}
-
-
-
{t("systemStats.rowPid", "PID")}
-
{system?.pid ?? "—"}
-
-
-
- -
-

{t("systemStats.sectionSystem", "System")}

-
-
-
{t("systemStats.rowMemoryUsed", "Memory Used")}
-
- - {system ? formatBytes(usedSystemMem) : "—"} - - - {system ? `${toPercent(usedSystemMem, system.systemTotalMem)} of ${formatBytes(system.systemTotalMem)}` : ""} - -
-
-
-
-
-
-
-
-
{t("systemStats.rowMemoryFree", "Memory Free")}
-
{system ? formatBytes(system.systemFreeMem) : "—"}
-
-
-
- -
-

{t("systemStats.sectionTasks", "Tasks")}

-
-
-
{t("systemStats.rowTotal", "Total")}
-
{taskStats?.total ?? 0}
-
- {Object.entries(taskStats?.byColumn ?? {}).map(([column, count]) => ( -
-
{column}
-
{count}
-
- ))} -
-
- -
-

{t("systemStats.sectionAgents", "Agents")}

-
-
-
{t("systemStats.agentIdle", "idle")}
-
{taskStats?.agents.idle ?? 0}
-
-
-
{t("systemStats.agentActive", "active")}
-
{taskStats?.agents.active ?? 0}
-
-
-
{t("systemStats.agentRunning", "running")}
-
{taskStats?.agents.running ?? 0}
-
-
-
{t("systemStats.agentError", "error")}
-
{taskStats?.agents.error ?? 0}
-
-
-
- -
-

- - {t("systemStats.sectionVitest", "Vitest Controls")} -

-
-
-
{t("systemStats.vitestProcesses", "Vitest Processes")}
-
{vitestProcessCount ?? "—"}
-
-
- -
-
- -
- - - -
- -
- { - const nextValue = Number.parseInt(event.target.value, 10); - void persistKillThreshold(Number.isNaN(nextValue) ? 90 : nextValue); - }} - /> - { - const nextValue = Number.parseInt(event.target.value, 10); - void persistKillThreshold(Number.isNaN(nextValue) ? 90 : nextValue); - }} - onBlur={() => { - void persistKillThreshold(killThreshold); - }} - /> -
-
- - {killResult &&

{t("systemStats.killedProcesses", "Killed {{count}} processes", { count: killResult.killed })}

} -

{t("systemStats.lastAutoKill", "Last auto-kill: {{time}}", { time: lastAutoKillLabel })}

- {settingsError &&

{settingsError}

} -
-
-
- )} - - {error && stats &&
{t("systemStats.footerRefreshFailed", "Latest refresh failed: {{error}}", { error })}
} - - - ); -} diff --git a/packages/dashboard/app/components/__tests__/AppModals.test.tsx b/packages/dashboard/app/components/__tests__/AppModals.test.tsx index 37a23c02c3..ba44e44821 100644 --- a/packages/dashboard/app/components/__tests__/AppModals.test.tsx +++ b/packages/dashboard/app/components/__tests__/AppModals.test.tsx @@ -76,14 +76,6 @@ vi.mock("../NewTaskModal", () => ({ NewTaskModal: () => null, })); -const mockSystemStatsModalProps = vi.fn(); -vi.mock("../SystemStatsModal", () => ({ - SystemStatsModal: (props: any) => { - mockSystemStatsModalProps(props); - return null; - }, -})); - const mockActivityLogModalProps = vi.fn(); vi.mock("../ActivityLogModal", () => ({ ActivityLogModal: (props: any) => { @@ -182,7 +174,6 @@ describe("AppModals", () => { fileBrowserInitialFile: null, usageOpen: false, usageAnchorRect: null, - systemStatsOpen: false, schedulesOpen: false, newTaskModalOpen: false, activityLogOpen: false, @@ -220,8 +211,6 @@ describe("AppModals", () => { setFileWorkspace: vi.fn(), openUsage: vi.fn(), closeUsage: vi.fn(), - openSystemStats: vi.fn(), - closeSystemStats: vi.fn(), openSchedules: vi.fn(), closeSchedules: vi.fn(), openNewTask: vi.fn(), @@ -257,7 +246,6 @@ describe("AppModals", () => { mockModelOnboardingModalProps.mockClear(); mockActivityLogModalProps.mockClear(); mockSettingsModalProps.mockClear(); - mockSystemStatsModalProps.mockClear(); mockTodoModalProps.mockClear(); }); @@ -530,45 +518,6 @@ describe("AppModals", () => { }); }); - describe("SystemStatsModal wiring", () => { - const commonProps = { - tasks: [], - projects: [], - currentProject: null, - toasts: mockToasts, - removeToast: vi.fn(), - projectActions: { handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }, - taskHandlers: { handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }, - taskOperations: { moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }, - deepLink: { handleDetailClose: vi.fn() }, - settings: mockSettings, - }; - - it("passes modal manager state and projectId through to SystemStatsModal", () => { - const closeSystemStats = vi.fn(); - render( - , - ); - - expect(mockSystemStatsModalProps).toHaveBeenCalledTimes(1); - expect(mockSystemStatsModalProps).toHaveBeenCalledWith( - expect.objectContaining({ - isOpen: true, - onClose: expect.any(Function), - projectId: "proj-system", - }), - ); - - mockSystemStatsModalProps.mock.calls[0][0].onClose(); - expect(closeSystemStats).toHaveBeenCalledTimes(1); - }); - }); - describe("task detail history wiring", () => { const commonProps = { projectId: "proj-1", diff --git a/packages/dashboard/app/components/__tests__/Header.test.tsx b/packages/dashboard/app/components/__tests__/Header.test.tsx index 2640be0b6f..b5225d3d4a 100644 --- a/packages/dashboard/app/components/__tests__/Header.test.tsx +++ b/packages/dashboard/app/components/__tests__/Header.test.tsx @@ -113,18 +113,6 @@ describe("Header", () => { expect(screen.getByTitle("Import from GitHub")).toBeDefined(); }); - it("renders system stats button on desktop when handler is provided", () => { - renderHeader({ onOpenSystemStats: vi.fn() }, "desktop"); - expect(screen.getByTitle("System Stats")).toBeDefined(); - }); - - it("calls onOpenSystemStats when system stats button is clicked", () => { - const onOpenSystemStats = vi.fn(); - renderHeader({ onOpenSystemStats }, "desktop"); - fireEvent.click(screen.getByTitle("System Stats")); - expect(onOpenSystemStats).toHaveBeenCalled(); - }); - it("calls onOpenSettings when settings button is clicked", () => { const onOpenSettings = vi.fn(); renderHeader({ onOpenSettings }); diff --git a/packages/dashboard/app/components/__tests__/MobileNavBar.test.tsx b/packages/dashboard/app/components/__tests__/MobileNavBar.test.tsx index 45eca56a80..ccd3d1d192 100644 --- a/packages/dashboard/app/components/__tests__/MobileNavBar.test.tsx +++ b/packages/dashboard/app/components/__tests__/MobileNavBar.test.tsx @@ -33,7 +33,6 @@ const createDefaultProps = () => ({ modalOpen: false, onOpenSettings: vi.fn(), onOpenActivityLog: vi.fn(), - onOpenSystemStats: vi.fn(), onOpenMailbox: vi.fn(), onOpenNodes: vi.fn(), mailboxUnreadCount: 0, @@ -393,7 +392,6 @@ describe("MobileNavBar", () => { expect(screen.getByTestId("mobile-more-item-mailbox")).toBeDefined(); expect(screen.getByTestId("mobile-more-item-activity")).toBeDefined(); - expect(screen.getByTestId("mobile-more-item-system-stats")).toBeDefined(); expect(screen.getByTestId("mobile-more-item-git")).toBeDefined(); expect(screen.getByTestId("mobile-more-item-terminal")).toBeDefined(); expect(screen.getByTestId("mobile-more-item-files")).toBeDefined(); @@ -563,17 +561,6 @@ describe("MobileNavBar", () => { expect(props.onOpenActivityLog).toHaveBeenCalledOnce(); }); - it("system stats item in more sheet calls onOpenSystemStats", () => { - const props = createDefaultProps(); - const { container } = render(); - - fireEvent.click(screen.getByTestId("mobile-nav-tab-more")); - fireEvent.click(screen.getByTestId("mobile-more-item-system-stats")); - - expect(container.querySelector(".mobile-more-sheet")).toBeNull(); - expect(props.onOpenSystemStats).toHaveBeenCalledOnce(); - }); - it("closes sheet and calls handler when item is clicked", () => { const props = createDefaultProps(); const { container } = render(); diff --git a/packages/dashboard/app/components/__tests__/SystemStatsModal.test.tsx b/packages/dashboard/app/components/__tests__/SystemStatsModal.test.tsx deleted file mode 100644 index 50d7f9e487..0000000000 --- a/packages/dashboard/app/components/__tests__/SystemStatsModal.test.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { SystemStatsModal } from "../SystemStatsModal"; - -vi.mock("lucide-react", () => ({ - Monitor: (props: { className?: string }) => , - RefreshCw: (props: { className?: string }) => , - ShieldAlert: (props: { className?: string }) => , - Skull: (props: { className?: string }) => , - X: (props: { className?: string }) => , -})); - -const mockFetchSystemStats = vi.fn(); -const mockFetchGlobalSettings = vi.fn(); -const mockKillVitestProcesses = vi.fn(); -const mockUpdateGlobalSettings = vi.fn(); - -vi.mock("../../api", () => ({ - fetchSystemStats: (...args: unknown[]) => mockFetchSystemStats(...args), - fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args), - killVitestProcesses: (...args: unknown[]) => mockKillVitestProcesses(...args), - updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args), -})); - -const sampleStats = { - systemStats: { - rss: 5 * 1024 * 1024 * 1024, - heapUsed: 900 * 1024 * 1024, - heapTotal: 1200 * 1024 * 1024, - heapLimit: 1000 * 1024 * 1024, - external: 50 * 1024 * 1024, - arrayBuffers: 20 * 1024 * 1024, - cpuPercent: 68.4, - loadAvg: [1.2, 0.8, 0.5] as [number, number, number], - cpuCount: 8, - systemTotalMem: 10 * 1024 * 1024 * 1024, - systemFreeMem: 1024 * 1024 * 1024, - pid: 12345, - nodeVersion: "v22.0.0", - platform: "darwin/arm64", - }, - taskStats: { - total: 6, - byColumn: { - triage: 1, - todo: 2, - "in-progress": 1, - "in-review": 1, - done: 1, - archived: 0, - }, - active: 2, - agents: { - idle: 1, - active: 2, - running: 0, - error: 1, - }, - }, - vitestProcessCount: 2, - vitestLastAutoKillAt: "2026-04-27T12:00:00.000Z", -}; - -describe("SystemStatsModal", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockFetchSystemStats.mockResolvedValue(sampleStats); - mockFetchGlobalSettings.mockResolvedValue({ - vitestAutoKillEnabled: true, - vitestKillThresholdPct: 90, - }); - mockKillVitestProcesses.mockResolvedValue({ killed: 2, pids: [111, 222] }); - mockUpdateGlobalSettings.mockResolvedValue({}); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("shows loading state while initial stats are fetched", async () => { - mockFetchSystemStats.mockReturnValue(new Promise(() => undefined)); - - render(); - - expect(await screen.findByText("Loading system stats…")).toBeDefined(); - }); - - it("renders fetched metrics across all sections", async () => { - render(); - - await waitFor(() => { - expect(mockFetchSystemStats).toHaveBeenCalledWith("proj-1"); - expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1); - }); - - expect(screen.getByText("System Stats")).toBeDefined(); - expect(await screen.findByText("Process")).toBeDefined(); - expect(screen.getByText("CPU & Load")).toBeDefined(); - expect(screen.getByText("System")).toBeDefined(); - expect(screen.getByText("Tasks")).toBeDefined(); - expect(screen.getByText("Agents")).toBeDefined(); - expect(screen.getByText("Vitest Controls")).toBeDefined(); - - expect(screen.getByText("5.00 GB")).toBeDefined(); - expect(screen.getByText("900 MB")).toBeDefined(); - expect(screen.getByText("9.00 GB")).toBeDefined(); - expect(screen.getByText("90.0% of 10.00 GB")).toBeDefined(); - - const memoryUsageProgress = screen.getByRole("progressbar", { - name: "System memory used: 90.0% (9.00 GB of 10.00 GB)", - }); - expect(memoryUsageProgress).toHaveAttribute("aria-valuenow", "90"); - expect(memoryUsageProgress).toHaveAttribute("aria-valuemin", "0"); - expect(memoryUsageProgress).toHaveAttribute("aria-valuemax", "100"); - expect(memoryUsageProgress.className).toContain("system-stats-modal__memory-progress-track--critical"); - const criticalFill = memoryUsageProgress.querySelector(".system-stats-modal__memory-progress-fill"); - expect(criticalFill?.className).toContain("system-stats-modal__memory-progress-fill--critical"); - - expect(screen.getByText("68.4%")).toBeDefined(); - const cpuUsageProgress = screen.getByRole("progressbar", { - name: "App CPU usage: 68.4%", - }); - expect(cpuUsageProgress).toHaveAttribute("aria-valuenow", "68"); - expect(cpuUsageProgress.className).toContain("system-stats-modal__memory-progress-track--normal"); - - expect(screen.getByText("1.20 0.80 0.50")).toBeDefined(); - expect(screen.getByText("Vitest Processes")).toBeDefined(); - expect(screen.getByText(/Last auto-kill:/)).toBeDefined(); - - const criticalValues = document.querySelectorAll(".system-stats-modal__value--critical"); - expect(criticalValues.length).toBeGreaterThan(0); - }); - - it("keeps memory value and bar severity aligned for warning thresholds", async () => { - mockFetchSystemStats.mockResolvedValue({ - ...sampleStats, - systemStats: { - ...sampleStats.systemStats, - systemFreeMem: 2 * 1024 * 1024 * 1024, - }, - }); - - render(); - - const memoryUsageProgress = await screen.findByRole("progressbar", { - name: "System memory used: 80.0% (8.00 GB of 10.00 GB)", - }); - expect(memoryUsageProgress.className).toContain("system-stats-modal__memory-progress-track--warning"); - const warningFill = memoryUsageProgress.querySelector(".system-stats-modal__memory-progress-fill"); - expect(warningFill?.className).toContain("system-stats-modal__memory-progress-fill--warning"); - - const memoryValue = screen.getByText("8.00 GB"); - expect(memoryValue.className).toContain("system-stats-modal__value--warning"); - }); - - it("shows refresh button icon when the modal is open", async () => { - render(); - - const refreshButton = await screen.findByRole("button", { name: "Refresh system stats" }); - const refreshIcon = screen.getByTestId("icon-refresh"); - - expect(refreshButton).toBeDefined(); - expect(refreshIcon).toBeDefined(); - expect(refreshButton.contains(refreshIcon)).toBe(true); - }); - - it("shows auto-refresh indicator when stats are loaded", async () => { - render(); - - expect(await screen.findByText("Auto-refresh · 5s")).toBeDefined(); - expect(screen.getByText(/Updated|Waiting for first update/)).toBeDefined(); - }); - - it("applies spinning class to refresh icon during background refresh", async () => { - vi.useFakeTimers(); - - let callCount = 0; - let resolveBackgroundRefresh: (() => void) | undefined; - const backgroundRefreshPromise = new Promise((resolve) => { - resolveBackgroundRefresh = () => resolve(sampleStats); - }); - - mockFetchSystemStats.mockImplementation(() => { - callCount += 1; - if (callCount === 1) { - return Promise.resolve(sampleStats); - } - return backgroundRefreshPromise; - }); - - render(); - await act(async () => { - await Promise.resolve(); - }); - expect(screen.getByText("Auto-refresh · 5s")).toBeDefined(); - - act(() => { - vi.advanceTimersByTime(5_000); - }); - await act(async () => { - await Promise.resolve(); - }); - - expect(screen.getByTestId("icon-refresh").className).toContain("system-stats-modal__refresh--spinning"); - - resolveBackgroundRefresh?.(); - await act(async () => { - await Promise.resolve(); - }); - }); - - it("shows error state when initial fetch fails", async () => { - mockFetchSystemStats.mockRejectedValue(new Error("stats unavailable")); - - render(); - - expect(await screen.findByRole("alert")).toHaveTextContent("stats unavailable"); - }); - - it("requires a confirmation click before killing vitest processes", async () => { - render(); - - const killButton = await screen.findByRole("button", { name: /Kill Vitest Processes/i }); - - fireEvent.click(killButton); - expect(mockKillVitestProcesses).not.toHaveBeenCalled(); - expect(screen.getByRole("button", { name: /Confirm Kill\?/i })).toBeDefined(); - - fireEvent.click(screen.getByRole("button", { name: /Confirm Kill\?/i })); - - await waitFor(() => { - expect(mockKillVitestProcesses).toHaveBeenCalledWith("proj-1"); - expect(screen.getByText("Killed 2 processes")).toBeDefined(); - }); - }); - - it("persists auto-kill toggle changes", async () => { - render(); - - const toggle = (await screen.findByLabelText("Auto-kill vitest on memory pressure")) as HTMLInputElement; - expect(toggle.checked).toBe(true); - - fireEvent.click(toggle); - - await waitFor(() => { - expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestAutoKillEnabled: false }); - }); - }); - - it("clamps threshold input to allowed range", async () => { - render(); - - const thresholdInput = (await screen.findByLabelText("Kill threshold (%)")) as HTMLInputElement; - - fireEvent.change(thresholdInput, { target: { value: "20" } }); - await waitFor(() => { - expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestKillThresholdPct: 50 }); - }); - - fireEvent.change(thresholdInput, { target: { value: "120" } }); - await waitFor(() => { - expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestKillThresholdPct: 99 }); - }); - }); - - it("persists threshold changes from the slider control", async () => { - render(); - - const thresholdSlider = (await screen.findByLabelText("Kill threshold slider (%)")) as HTMLInputElement; - fireEvent.change(thresholdSlider, { target: { value: "95" } }); - - await waitFor(() => { - expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestKillThresholdPct: 95 }); - }); - }); - - it("shows deterministic fallback copy when app CPU percentage is unavailable", async () => { - mockFetchSystemStats.mockResolvedValue({ - ...sampleStats, - systemStats: { - ...sampleStats.systemStats, - cpuPercent: null, - }, - }); - - render(); - - expect(await screen.findByText("Sampling…")).toBeDefined(); - const cpuUsageProgress = screen.getByRole("progressbar", { - name: "App CPU usage unavailable: waiting for another sample", - }); - expect(cpuUsageProgress).toHaveAttribute("aria-valuenow", "0"); - }); - - it("shows fallback text when last auto-kill timestamp is unavailable", async () => { - mockFetchSystemStats.mockResolvedValue({ - ...sampleStats, - vitestLastAutoKillAt: null, - }); - - render(); - - expect(await screen.findByText("Last auto-kill: Not yet")).toBeDefined(); - }); - - it("refreshes every 5 seconds while open and stops when closed", async () => { - vi.useFakeTimers(); - - const { rerender } = render(); - - await act(async () => { - await Promise.resolve(); - }); - expect(mockFetchSystemStats).toHaveBeenCalledTimes(1); - - await act(async () => { - await vi.advanceTimersByTimeAsync(5_000); - }); - expect(mockFetchSystemStats).toHaveBeenCalledTimes(2); - - rerender(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(10_000); - }); - - expect(mockFetchSystemStats).toHaveBeenCalledTimes(2); - }); -}); diff --git a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx index c5f8dd392e..6ee3f823eb 100644 --- a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx +++ b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx @@ -318,7 +318,6 @@ function AppShellMobileHarness({ tasks }: { tasks: Task[] }) { keyboardOpen={keyboardOpen} onOpenSettings={vi.fn()} onOpenActivityLog={vi.fn()} - onOpenSystemStats={vi.fn()} onOpenMailbox={vi.fn()} onOpenGitManager={vi.fn()} onOpenWorkflowEditor={vi.fn()} diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index 149bee7aec..c9cbd8bde8 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -12,6 +12,7 @@ import { TeamArea } from "./areas/TeamArea"; import { EcosystemArea } from "./areas/EcosystemArea"; import { GithubArea } from "./areas/GithubArea"; import { SignalsArea } from "./areas/SignalsArea"; +import { SystemStatsArea } from "./areas/SystemStatsArea"; import { MissionControlPanel } from "./MissionControlPanel"; import { SdlcFunnel } from "./SdlcFunnel"; import { Bar, type BarDatum } from "./charts/Bar"; @@ -31,6 +32,7 @@ type SubViewId = | "ecosystem" | "github" | "signals" + | "system" | "mission-control"; interface SubView { @@ -54,6 +56,7 @@ function useSubViews(): SubView[] { { id: "ecosystem", label: t("commandCenter.tabs.ecosystem", "Ecosystem") }, { id: "github", label: t("commandCenter.tabs.github", "GitHub") }, { id: "signals", label: t("commandCenter.tabs.signals", "Signals") }, + { id: "system", label: t("commandCenter.tabs.system", "System") }, { id: "mission-control", label: t("commandCenter.tabs.missionControl", "Mission Control") }, ]; } @@ -441,6 +444,8 @@ export function CommandCenter() { return ; case "signals": return ; + case "system": + return ; case "mission-control": return ; default: 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 b46e863289..a48bc26f7b 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 @@ -11,6 +11,13 @@ vi.mock("../../../api/legacy", () => ({ api: (path: string, opts?: RequestInit) => apiMock(path, opts), })); +vi.mock("../../../api", () => ({ + fetchSystemStats: () => Promise.resolve(systemStatsFixture()), + fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }), + killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }), + updateGlobalSettings: () => Promise.resolve({}), +})); + function emptyTokenFixture() { return { totals: { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0 }, @@ -128,6 +135,37 @@ function populatedActivityFixture() { }; } +function systemStatsFixture() { + const gb = 1024 * 1024 * 1024; + const mb = 1024 * 1024; + return { + systemStats: { + rss: 2 * gb, + heapUsed: 500 * mb, + heapTotal: 700 * mb, + heapLimit: 1 * gb, + external: 20 * mb, + arrayBuffers: 8 * mb, + cpuPercent: 12, + loadAvg: [0.1, 0.2, 0.3] as [number, number, number], + cpuCount: 8, + systemTotalMem: 8 * gb, + systemFreeMem: 4 * gb, + pid: 456, + nodeVersion: "v22.0.0", + platform: "darwin/arm64", + }, + taskStats: { + total: 1, + byColumn: { todo: 1 }, + active: 0, + agents: { idle: 1, active: 0, running: 0, error: 0 }, + }, + vitestProcessCount: 0, + vitestLastAutoKillAt: null, + }; +} + function mockOverviewApi({ populated = false }: { populated?: boolean } = {}) { apiMock.mockImplementation((path: string) => { if (path.startsWith("/command-center/tokens")) return Promise.resolve(populated ? populatedTokenFixture() : emptyTokenFixture()); @@ -136,6 +174,8 @@ function mockOverviewApi({ populated = false }: { populated?: boolean } = {}) { if (path.startsWith("/command-center/github")) return Promise.resolve(emptyGithubFixture()); if (path.startsWith("/command-center/team")) return Promise.resolve(emptyTeamFixture()); if (path.startsWith("/command-center/signals")) return Promise.resolve({ totalSignals: 0, open: 0, resolved: 0, mttr: { value: null, unavailable: true }, bySource: [], bySeverity: [] }); + if (path === "/system-stats") return Promise.resolve(systemStatsFixture()); + if (path === "/settings/global") return Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }); if (path === "/command-center/live") { return Promise.resolve({ capturedAt: "2026-06-18T00:00:00.000Z", @@ -224,6 +264,12 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => { const githubPanel = screen.getByTestId("command-center-panel-github"); expect(githubPanel).toBe(screen.getByRole("tabpanel")); assertScrollOwnerContract(githubPanel); + + fireEvent.click(screen.getByTestId("command-center-tab-system")); + const systemPanel = screen.getByTestId("command-center-panel-system"); + expect(systemPanel).toBe(screen.getByRole("tabpanel")); + await screen.findByTestId("cc-area-system"); + assertScrollOwnerContract(systemPanel); }); it("preserves the mobile scroll owner when the populated Overview charts render", async () => { 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 096971a721..ad94270a77 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -11,6 +11,13 @@ vi.mock("../../../api/legacy", () => ({ api: (path: string, opts?: RequestInit) => apiMock(path, opts), })); +vi.mock("../../../api", () => ({ + fetchSystemStats: () => Promise.resolve(systemStatsFixture()), + fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }), + killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }), + updateGlobalSettings: () => Promise.resolve({}), +})); + function tokenFixture(totalTokens = 1_500) { return { from: "2026-06-08", @@ -182,6 +189,37 @@ function liveFixture(columns: Array<{ column: string; count: number }> = [{ colu }; } +function systemStatsFixture() { + const gb = 1024 * 1024 * 1024; + const mb = 1024 * 1024; + return { + systemStats: { + rss: 2 * gb, + heapUsed: 500 * mb, + heapTotal: 700 * mb, + heapLimit: 1 * gb, + external: 20 * mb, + arrayBuffers: 8 * mb, + cpuPercent: 12, + loadAvg: [0.1, 0.2, 0.3] as [number, number, number], + cpuCount: 8, + systemTotalMem: 8 * gb, + systemFreeMem: 4 * gb, + pid: 456, + nodeVersion: "v22.0.0", + platform: "darwin/arm64", + }, + taskStats: { + total: 1, + byColumn: { todo: 1 }, + active: 0, + agents: { idle: 1, active: 0, running: 0, error: 0 }, + }, + vitestProcessCount: 0, + vitestLastAutoKillAt: null, + }; +} + function mockOverviewApi({ tokens = tokenFixture(), tools = toolsFixture(), @@ -211,6 +249,8 @@ function mockOverviewApi({ if (path === "/command-center/live") { return live instanceof Error ? Promise.reject(live) : Promise.resolve(live); } + if (path === "/system-stats") return Promise.resolve(systemStatsFixture()); + if (path === "/settings/global") return Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }); return Promise.reject(new Error(`Unhandled api path: ${path}`)); }); } @@ -520,8 +560,8 @@ describe("CommandCenter shell", () => { render(); const tablist = screen.getByRole("tablist"); const tabs = within(tablist).getAllByRole("tab"); - // Overview, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, Mission Control. - expect(tabs.length).toBe(10); + // Overview, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, System, Mission Control. + expect(tabs.length).toBe(11); // roving tabindex: exactly one tab is focusable. const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0"); expect(focusable.length).toBe(1); @@ -536,6 +576,18 @@ describe("CommandCenter shell", () => { expect(screen.getByTestId("command-center-panel-tokens")).toBeTruthy(); }); + it("renders and routes the System tab exactly once", async () => { + mockOverviewApi(); + render(); + expect(screen.getAllByTestId("command-center-tab-system")).toHaveLength(1); + + fireEvent.click(screen.getByTestId("command-center-tab-system")); + expect(screen.getByTestId("command-center-tab-system").getAttribute("aria-selected")).toBe("true"); + expect(screen.getByTestId("command-center-panel-system")).toBeTruthy(); + await screen.findByTestId("cc-area-system"); + expect(screen.getByTestId("cc-system-cpu-gauge")).toBeTruthy(); + }); + it("renders and routes the GitHub tab exactly once", async () => { mockOverviewApi({ github: githubFixture(4, 2) }); render(); @@ -642,6 +694,7 @@ describe("CommandCenter shell", () => { "ecosystem", "github", "signals", + "system", "mission-control", "team", ]) { diff --git a/packages/dashboard/app/components/command-center/__tests__/SystemStatsArea.test.tsx b/packages/dashboard/app/components/command-center/__tests__/SystemStatsArea.test.tsx new file mode 100644 index 0000000000..9e046e417a --- /dev/null +++ b/packages/dashboard/app/components/command-center/__tests__/SystemStatsArea.test.tsx @@ -0,0 +1,229 @@ +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 mockFetchGlobalSettings = vi.fn(); +const mockKillVitestProcesses = vi.fn(); +const mockUpdateGlobalSettings = vi.fn(); + +vi.mock("../../../api", () => ({ + fetchSystemStats: (...args: unknown[]) => mockFetchSystemStats(...args), + fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args), + killVitestProcesses: (...args: unknown[]) => mockKillVitestProcesses(...args), + updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args), +})); + +const gb = 1024 * 1024 * 1024; +const mb = 1024 * 1024; + +type SystemStatsFixture = ReturnType; +type SystemStatsFixtureOverrides = Partial> & { + systemStats?: Partial; + taskStats?: Partial> & { + agents?: Partial; + }; +}; + +function sampleStats(overrides: SystemStatsFixtureOverrides = {}) { + return { + ...baseStats(), + ...overrides, + systemStats: { + ...baseStats().systemStats, + ...overrides.systemStats, + }, + taskStats: { + ...baseStats().taskStats, + ...overrides.taskStats, + agents: { + ...baseStats().taskStats.agents, + ...overrides.taskStats?.agents, + }, + }, + }; +} + +function baseStats() { + return { + systemStats: { + rss: 5 * gb, + heapUsed: 900 * mb, + heapTotal: 1200 * mb, + heapLimit: 1000 * mb, + external: 50 * mb, + arrayBuffers: 20 * mb, + cpuPercent: 68.4, + loadAvg: [1.2, 0.8, 0.5] as [number, number, number], + cpuCount: 8, + systemTotalMem: 10 * gb, + systemFreeMem: 1 * gb, + pid: 12345, + nodeVersion: "v22.0.0", + platform: "darwin/arm64", + }, + taskStats: { + total: 6, + byColumn: { + triage: 1, + todo: 2, + "in-progress": 1, + "in-review": 1, + done: 1, + }, + active: 2, + agents: { + idle: 1, + active: 2, + running: 0, + error: 1, + }, + }, + vitestProcessCount: 2, + vitestLastAutoKillAt: "2026-04-27T12:00:00.000Z", + }; +} + +describe("SystemStatsArea", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchSystemStats.mockResolvedValue(sampleStats()); + mockFetchGlobalSettings.mockResolvedValue({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }); + mockKillVitestProcesses.mockResolvedValue({ killed: 2, pids: [111, 222] }); + mockUpdateGlobalSettings.mockResolvedValue({}); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders gauges, trends, bars, details, and Vitest controls for populated stats", async () => { + render(); + + await waitFor(() => { + expect(mockFetchSystemStats).toHaveBeenCalledWith("proj-1"); + expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1); + }); + + expect(await screen.findByTestId("cc-area-system")).toBeInTheDocument(); + expect(screen.getByTestId("cc-system-cpu-gauge")).toHaveTextContent("68%"); + expect(screen.getByTestId("cc-system-mem-gauge")).toHaveTextContent("90%"); + expect(screen.getByTestId("cc-system-heap-gauge")).toHaveTextContent("90%"); + expect(screen.getByTestId("cc-system-cpu-trend")).toBeInTheDocument(); + expect(screen.getByTestId("cc-system-memory-trend")).toBeInTheDocument(); + expect(screen.getByTestId("cc-system-tasks-bar")).toHaveTextContent("in-progress"); + expect(screen.getByTestId("cc-system-agents-bar")).toHaveTextContent("active"); + expect(screen.getByTestId("cc-system-details-grid")).toHaveTextContent("RSS"); + expect(screen.getByTestId("cc-system-details-grid")).toHaveTextContent("5.00 GB"); + expect(screen.getByTestId("cc-system-vitest-controls")).toHaveTextContent("Vitest Processes"); + }); + + it("renders the first-sample CPU state safely without NaN", async () => { + mockFetchSystemStats.mockResolvedValue(sampleStats({ systemStats: { cpuPercent: null } })); + + render(); + + await screen.findByTestId("cc-area-system"); + expect(screen.getByTestId("cc-system-cpu-gauge")).toHaveTextContent("—"); + expect(screen.getByTestId("cc-system-cpu-gauge")).toHaveTextContent("Sampling"); + expect(screen.getByTestId("cc-area-system")).not.toHaveTextContent("NaN"); + }); + + it("renders zero-value task and agent bars when collections are empty", async () => { + mockFetchSystemStats.mockResolvedValue(sampleStats({ + taskStats: { + total: 0, + byColumn: {}, + active: 0, + agents: { idle: 0, active: 0, running: 0, error: 0 }, + }, + })); + + render(); + + await screen.findByTestId("cc-area-system"); + const taskBars = screen.getByTestId("cc-system-tasks-bar"); + const agentBars = screen.getByTestId("cc-system-agents-bar"); + expect(within(taskBars).getByText("triage")).toBeInTheDocument(); + expect(within(agentBars).getByText("idle")).toBeInTheDocument(); + expect(within(taskBars).getAllByText("0").length).toBeGreaterThan(0); + expect(within(agentBars).getAllByText("0").length).toBeGreaterThan(0); + expect(screen.getByTestId("cc-area-system")).not.toHaveTextContent("NaN"); + }); + + it("keeps the last stats visible when a later poll fails", async () => { + vi.useFakeTimers(); + mockFetchSystemStats.mockResolvedValueOnce(sampleStats()).mockRejectedValueOnce(new Error("poll failed")); + + render(); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId("cc-area-system")).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + + expect(screen.getByTestId("cc-area-system")).toHaveTextContent("Latest refresh failed: poll failed"); + expect(screen.getByTestId("cc-system-details-grid")).toHaveTextContent("RSS"); + }); + + it("shows the initial error state when the first fetch fails", async () => { + mockFetchSystemStats.mockRejectedValue(new Error("initial failure")); + + render(); + + expect(await screen.findByTestId("cc-area-system-error")).toHaveTextContent("initial failure"); + }); + + it("confirms before killing Vitest and persists settings changes", async () => { + render(); + await screen.findByTestId("cc-area-system"); + + const killButton = screen.getByTestId("cc-system-kill-vitest"); + fireEvent.click(killButton); + expect(killButton).toHaveTextContent("Confirm Kill?"); + fireEvent.click(killButton); + + await waitFor(() => { + expect(mockKillVitestProcesses).toHaveBeenCalledWith("proj-1"); + }); + expect(await screen.findByText("Killed 2 processes")).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("Auto-kill vitest on memory pressure")); + await waitFor(() => { + expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestAutoKillEnabled: false }); + }); + + fireEvent.change(screen.getByLabelText("Kill threshold (%)"), { target: { value: "120" } }); + await waitFor(() => { + expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestKillThresholdPct: 99 }); + }); + }); + + it("polls every five seconds and clears the interval on unmount", async () => { + vi.useFakeTimers(); + const { unmount } = render(); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId("cc-area-system")).toBeInTheDocument(); + expect(mockFetchSystemStats).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + expect(mockFetchSystemStats).toHaveBeenCalledTimes(2); + + unmount(); + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + expect(mockFetchSystemStats).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/dashboard/app/components/command-center/areas/SystemStatsArea.css b/packages/dashboard/app/components/command-center/areas/SystemStatsArea.css new file mode 100644 index 0000000000..30b25f0f2d --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/SystemStatsArea.css @@ -0,0 +1,122 @@ +/* +FNXC:CommandCenter 2026-06-18-00:00: +The Command Center System area replaces the standalone System Stats modal with graph-heavy telemetry while preserving the Command Center mobile scroll contract; keep area-specific styling layout-only and avoid nested overflow containers so .cc-tabpanel remains the sole vertical scroller. +*/ + +.cc-system-refresh { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + color: var(--text-muted); + font-size: var(--font-size-sm); +} + +.cc-system-refresh .btn-icon { + flex: 0 0 auto; +} + +.cc-system-gauges .cc-stat-card { + min-block-size: 100%; +} + +.cc-system-chart-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.cc-system-chart-grid .cc-stat-card { + gap: var(--space-3); +} + +.cc-system-section-title-with-icon { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.cc-system-section-title-with-icon svg { + color: var(--text-muted); +} + +.cc-system-vitest-card { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); + padding: var(--space-3); +} + +.cc-system-vitest-card .btn { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.cc-system-toggle-row, +.cc-system-threshold-row, +.cc-system-threshold-controls { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.cc-system-toggle-row, +.cc-system-threshold-row { + color: var(--text-primary); + font-size: var(--font-size-sm); +} + +.cc-system-threshold-controls input[type="range"] { + accent-color: var(--color-accent); +} + +.cc-system-threshold-controls .input { + inline-size: 5rem; +} + +.cc-system-note { + margin: 0; + color: var(--text-muted); + font-size: var(--font-size-sm); +} + +.cc-system-note--error, +.cc-system-value--critical { + color: var(--color-error); +} + +.cc-system-value--warning { + color: var(--color-warning); +} + +.cc-system-note--success { + color: var(--color-success); +} + +@media (max-width: 768px) { + .cc-system-refresh { + align-items: flex-start; + justify-content: flex-start; + flex-direction: column; + } + + .cc-system-chart-grid { + grid-template-columns: 1fr; + } + + .cc-system-vitest-card, + .cc-system-toggle-row, + .cc-system-threshold-row, + .cc-system-threshold-controls { + align-items: stretch; + flex-direction: column; + inline-size: 100%; + } + + .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 new file mode 100644 index 0000000000..77ab97c1c3 --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/SystemStatsArea.tsx @@ -0,0 +1,440 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { RefreshCw, ShieldAlert, Skull } from "lucide-react"; +import { + fetchGlobalSettings, + fetchSystemStats, + killVitestProcesses, + updateGlobalSettings, + type KillVitestResponse, + type SystemStatsResponse, +} from "../../../api"; +import { Bar, type BarDatum } from "../charts/Bar"; +import { RadialGauge } from "../charts/RadialGauge"; +import { Sparkline } from "../charts/Sparkline"; +import { AreaShell } from "./AreaShell"; +import { formatCount } from "./areaShared"; +import "./SystemStatsArea.css"; + +type Severity = "normal" | "warning" | "critical"; + +interface SystemSample { + cpuPercent: number; + usedSystemMemPercent: number; + heapUsedPercent: number; +} + +const SYSTEM_STATS_POLL_MS = 5_000; +const MAX_SYSTEM_SAMPLES = 30; +const DEFAULT_TASK_COLUMNS = ["triage", "todo", "in-progress", "in-review", "done"]; +const AGENT_STATES = ["idle", "active", "running", "error"] as const; + +/* +FNXC:CommandCenter 2026-06-18-00:00: +System telemetry now lives in the Command Center System area; it polls /api/system-stats (no new endpoint) and keeps a bounded rolling sample buffer to render live CPU/memory trend sparklines. +*/ + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return "—"; + const mb = bytes / (1024 * 1024); + if (mb < 1024) return `${mb.toFixed(0)} MB`; + return `${(mb / 1024).toFixed(2)} GB`; +} + +function toPercent(used: number, total: number): string { + if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) return "—"; + return `${((used / total) * 100).toFixed(1)}%`; +} + +function heapSeverity(used: number, limit: number): Severity { + if (limit <= 0) return "normal"; + const pct = used / limit; + if (pct >= 0.85) return "critical"; + if (pct >= 0.65) return "warning"; + return "normal"; +} + +function rssSeverity(rss: number, totalSystemMem: number): Severity { + if (totalSystemMem <= 0) return "normal"; + const pct = rss / totalSystemMem; + if (pct >= 0.5) return "critical"; + if (pct >= 0.25) return "warning"; + return "normal"; +} + +function systemMemSeverity(used: number, total: number): Severity { + if (total <= 0) return "normal"; + const pct = used / total; + if (pct >= 0.9) return "critical"; + if (pct >= 0.75) return "warning"; + return "normal"; +} + +function cpuSeverity(percent: number | null, cores: number): Severity { + if (percent === null || !Number.isFinite(percent) || percent < 0) return "normal"; + const normalized = cores > 0 ? percent / cores : percent; + if (normalized >= 80) return "critical"; + if (normalized >= 50) return "warning"; + return "normal"; +} + +function severityClassName(severity: Severity): string { + if (severity === "critical") return "cc-system-value--critical"; + if (severity === "warning") return "cc-system-value--warning"; + return ""; +} + +function formatTimestamp(value: string | null | undefined, notYetLabel: string): string { + if (!value) return notYetLabel; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return notYetLabel; + return parsed.toLocaleString(); +} + +function safeRatio(used: number, total: number): number { + if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) return 0; + return Math.max(0, Math.min(1, used / total)); +} + +function clampPercent(value: number | null | undefined): number { + if (value === null || value === undefined || !Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, value)); +} + +function sampleFromStats(stats: SystemStatsResponse): SystemSample { + const system = stats.systemStats; + const usedSystemMem = system.systemTotalMem - system.systemFreeMem; + return { + cpuPercent: clampPercent(system.cpuPercent), + usedSystemMemPercent: safeRatio(usedSystemMem, system.systemTotalMem) * 100, + heapUsedPercent: safeRatio(system.heapUsed, system.heapLimit) * 100, + }; +} + +export function SystemStatsArea({ projectId }: { projectId?: string }) { + const { t } = useTranslation("app"); + const [stats, setStats] = useState(null); + const [samples, setSamples] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [autoKillEnabled, setAutoKillEnabled] = useState(true); + const [killThreshold, setKillThreshold] = useState(90); + const [isKilling, setIsKilling] = useState(false); + const [confirmKill, setConfirmKill] = useState(false); + const [killResult, setKillResult] = useState(null); + const [settingsError, setSettingsError] = useState(null); + const [lastRefreshedAt, setLastRefreshedAt] = useState(null); + + const loadStats = useCallback(async (options?: { preserveKillResult?: boolean }) => { + setLoading(true); + try { + const response = await fetchSystemStats(projectId); + setStats(response); + setSamples((prev) => [...prev, sampleFromStats(response)].slice(-MAX_SYSTEM_SAMPLES)); + setError(null); + setLastRefreshedAt(Date.now()); + if (!options?.preserveKillResult) { + setKillResult(null); + } + } catch (err) { + setError(err instanceof Error ? err.message : t("systemStats.errorLoadStats", "Failed to load system stats")); + } finally { + setLoading(false); + } + }, [projectId, t]); + + useEffect(() => { + void loadStats(); + const timer = window.setInterval(() => { + void loadStats(); + }, SYSTEM_STATS_POLL_MS); + return () => { + window.clearInterval(timer); + }; + }, [loadStats]); + + useEffect(() => { + let cancelled = false; + const loadSettings = async () => { + try { + const settings = await fetchGlobalSettings(); + if (cancelled) return; + setAutoKillEnabled(settings.vitestAutoKillEnabled ?? true); + setKillThreshold(settings.vitestKillThresholdPct ?? 90); + setSettingsError(null); + } catch (err) { + if (!cancelled) { + setSettingsError(err instanceof Error ? err.message : t("systemStats.errorLoadVitestSettings", "Failed to load vitest settings")); + } + } + }; + void loadSettings(); + return () => { + cancelled = true; + }; + }, [t]); + + const persistAutoKill = useCallback(async (enabled: boolean) => { + setAutoKillEnabled(enabled); + try { + await updateGlobalSettings({ vitestAutoKillEnabled: enabled }); + setSettingsError(null); + } catch (err) { + setSettingsError(err instanceof Error ? err.message : t("systemStats.errorSaveVitestSettings", "Failed to save vitest settings")); + } + }, [t]); + + const persistKillThreshold = useCallback(async (nextThreshold: number) => { + const clamped = Math.min(99, Math.max(50, Number.isFinite(nextThreshold) ? Math.round(nextThreshold) : 90)); + setKillThreshold(clamped); + + try { + await updateGlobalSettings({ vitestKillThresholdPct: clamped }); + setSettingsError(null); + } catch (err) { + setSettingsError(err instanceof Error ? err.message : t("systemStats.errorSaveVitestSettings", "Failed to save vitest settings")); + } + }, [t]); + + const handleKillVitest = useCallback(async () => { + if (isKilling) return; + if (!confirmKill) { + setConfirmKill(true); + return; + } + + setIsKilling(true); + try { + const result = await killVitestProcesses(projectId); + setKillResult(result); + setConfirmKill(false); + await loadStats({ preserveKillResult: true }); + } catch (err) { + setError(err instanceof Error ? err.message : t("systemStats.errorKillVitest", "Failed to kill vitest processes")); + } finally { + setIsKilling(false); + } + }, [confirmKill, isKilling, loadStats, projectId, t]); + + const system = stats?.systemStats; + const taskStats = stats?.taskStats; + const usedSystemMem = system ? system.systemTotalMem - system.systemFreeMem : 0; + const usedSystemMemRatio = system ? safeRatio(usedSystemMem, system.systemTotalMem) : 0; + const heapRatio = system ? safeRatio(system.heapUsed, system.heapLimit) : 0; + const cpuRatio = system?.cpuPercent === null || system?.cpuPercent === undefined ? null : clampPercent(system.cpuPercent) / 100; + const cpuPercentLabel = system?.cpuPercent === null || system?.cpuPercent === undefined ? t("systemStats.cpuSampling", "Sampling…") : `${system.cpuPercent.toFixed(1)}%`; + const refreshLabel = lastRefreshedAt + ? t("systemStats.updatedAt", "Updated {{time}}", { + time: new Date(lastRefreshedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }), + }) + : t("systemStats.waitingFirstUpdate", "Waiting for first update"); + + const taskBarData = useMemo(() => { + const byColumn = taskStats?.byColumn ?? {}; + const labels = Object.keys(byColumn).length > 0 ? Object.keys(byColumn) : DEFAULT_TASK_COLUMNS; + return labels.map((label) => ({ label, value: byColumn[label] ?? 0, valueLabel: formatCount(byColumn[label] ?? 0) })); + }, [taskStats?.byColumn]); + + const agentBarData = useMemo(() => { + const agents = taskStats?.agents; + return AGENT_STATES.map((state) => ({ + label: t(`systemStats.agent${state[0].toUpperCase()}${state.slice(1)}`, state), + value: agents?.[state] ?? 0, + valueLabel: formatCount(agents?.[state] ?? 0), + })); + }, [taskStats?.agents, t]); + + const detailRows = useMemo(() => { + const heapClassName = system ? severityClassName(heapSeverity(system.heapUsed, system.heapLimit)) : ""; + const rssClassName = system ? severityClassName(rssSeverity(system.rss, system.systemTotalMem)) : ""; + const systemMemClassName = system ? severityClassName(systemMemSeverity(usedSystemMem, system.systemTotalMem)) : ""; + const cpuClassName = system ? severityClassName(cpuSeverity(system.cpuPercent, system.cpuCount)) : ""; + return [ + { label: t("systemStats.rowAppCpu", "App CPU"), value: cpuPercentLabel, detail: system?.cpuPercent === null ? t("systemStats.cpuFirstSamplePending", "First sample pending") : t("systemStats.cpuProcessUsage", "process usage"), className: cpuClassName }, + { label: t("systemStats.rowRss", "RSS"), value: system ? formatBytes(system.rss) : "—", detail: system ? toPercent(system.rss, system.systemTotalMem) : "—", className: rssClassName }, + { label: t("systemStats.rowHeapUsed", "Heap Used"), value: system ? formatBytes(system.heapUsed) : "—", detail: system ? t("systemStats.rowHeapUsedDetail", "of {{total}}", { total: formatBytes(system.heapTotal) }) : "—", className: heapClassName }, + { label: t("systemStats.rowHeapLimit", "Heap Limit"), value: system ? formatBytes(system.heapLimit) : "—", detail: t("systemStats.rowHeapLimitDetail", "V8 limit") }, + { label: t("systemStats.rowExternal", "External"), value: system ? formatBytes(system.external) : "—" }, + { label: t("systemStats.rowArrayBuffers", "Array Buffers"), value: system ? formatBytes(system.arrayBuffers) : "—" }, + { label: t("systemStats.rowLoadAvg", "Load Avg"), value: system?.loadAvg.map((value) => value.toFixed(2)).join(" ") ?? "—" }, + { label: t("systemStats.rowCores", "Cores"), value: system?.cpuCount ?? "—" }, + { label: t("systemStats.rowPlatform", "Platform"), value: system?.platform ?? "—" }, + { label: t("systemStats.rowNode", "Node"), value: system?.nodeVersion ?? "—" }, + { label: t("systemStats.rowPid", "PID"), value: system?.pid ?? "—" }, + { label: t("systemStats.rowMemoryUsed", "Memory Used"), value: system ? formatBytes(usedSystemMem) : "—", detail: system ? `${toPercent(usedSystemMem, system.systemTotalMem)} of ${formatBytes(system.systemTotalMem)}` : "—", className: systemMemClassName }, + { label: t("systemStats.rowMemoryFree", "Memory Free"), value: system ? formatBytes(system.systemFreeMem) : "—" }, + ]; + }, [cpuPercentLabel, system, t, usedSystemMem]); + + const vitestProcessCount = stats?.vitestProcessCount; + const lastAutoKillLabel = formatTimestamp(stats?.vitestLastAutoKillAt, t("systemStats.notYet", "Not yet")); + const isBackgroundRefreshing = loading && Boolean(stats); + + return ( + +
+
+

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

+
+ {t("systemStats.autoRefresh", "Auto-refresh · 5s")} + {refreshLabel} + +
+
+ {error && stats ? ( +

+ {t("systemStats.footerRefreshFailed", "Latest refresh failed: {{error}}", { error })} +

+ ) : null} +
+
+ + {cpuPercentLabel} +
+
+ + {system ? `${formatBytes(usedSystemMem)} / ${formatBytes(system.systemTotalMem)}` : "—"} +
+
+ + {system ? `${formatBytes(system.heapUsed)} / ${formatBytes(system.heapLimit)}` : "—"} +
+
+
+ +
+

{t("commandCenter.system.trendsTitle", "Live trends")}

+
+
+
{t("commandCenter.system.cpuTrend", "CPU over time")}
+ sample.cpuPercent)} max={100} ariaLabel={t("commandCenter.system.cpuTrend", "CPU over time")} /> +
+
+
{t("commandCenter.system.memoryTrend", "Memory over time")}
+ sample.usedSystemMemPercent)} max={100} ariaLabel={t("commandCenter.system.memoryTrend", "Memory over time")} /> +
+
+
{t("commandCenter.system.heapTrend", "Heap over time")}
+ sample.heapUsedPercent)} max={100} ariaLabel={t("commandCenter.system.heapTrend", "Heap over time")} /> +
+
+
+ +
+

{t("commandCenter.system.workloadTitle", "Workload")}

+
+
+
{t("systemStats.sectionTasks", "Tasks")}
+ +
+
+
{t("systemStats.sectionAgents", "Agents")}
+ +
+
+
+ +
+

{t("commandCenter.system.detailsTitle", "Runtime details")}

+
+ {detailRows.map((row) => ( +
+
{row.label}
+
{row.value}
+ {row.detail ? {row.detail} : null} +
+ ))} +
+
+ +
+

+ + {t("systemStats.sectionVitest", "Vitest Controls")} +

+
+
+
{t("systemStats.vitestProcesses", "Vitest Processes")}
+
{vitestProcessCount ?? "—"}
+
+
+
{t("systemStats.lastAutoKill", "Last auto-kill: {{time}}", { time: "" }).trim()}
+
{lastAutoKillLabel}
+
+
+
+ + + + +
+ +
+ { + const nextValue = Number.parseInt(event.target.value, 10); + void persistKillThreshold(Number.isNaN(nextValue) ? 90 : nextValue); + }} + /> + { + const nextValue = Number.parseInt(event.target.value, 10); + void persistKillThreshold(Number.isNaN(nextValue) ? 90 : nextValue); + }} + onBlur={() => { + void persistKillThreshold(killThreshold); + }} + /> +
+
+ + {killResult ? ( +

0 ? "cc-system-note--success" : "cc-system-note--error"}`}> + {t("systemStats.killedProcesses", "Killed {{count}} processes", { count: killResult.killed })} +

+ ) : null} + {settingsError ?

{settingsError}

: null} +
+
+
+ ); +} diff --git a/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts b/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts index 8e051ad366..9beb72e72f 100644 --- a/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts @@ -205,29 +205,6 @@ describe("useModalManager", () => { expect(result.current.settingsInitialSection).toBeUndefined(); }); - it("tracks system stats modal state and includes it in anyModalOpen", () => { - const { result } = renderHook(() => - useModalManager({ projectId: "proj_1", planningSessions: [] }), - ); - - expect(result.current.systemStatsOpen).toBe(false); - expect(result.current.anyModalOpen).toBe(false); - - act(() => { - result.current.openSystemStats(); - }); - - expect(result.current.systemStatsOpen).toBe(true); - expect(result.current.anyModalOpen).toBe(true); - - act(() => { - result.current.closeSystemStats(); - }); - - expect(result.current.systemStatsOpen).toBe(false); - expect(result.current.anyModalOpen).toBe(false); - }); - it("accepts plain Task object for optimistic modal opening", () => { const task = createTask("FN-456"); const { result } = renderHook(() => diff --git a/packages/dashboard/app/hooks/useModalManager.ts b/packages/dashboard/app/hooks/useModalManager.ts index 31baca7195..91faf57be6 100644 --- a/packages/dashboard/app/hooks/useModalManager.ts +++ b/packages/dashboard/app/hooks/useModalManager.ts @@ -46,7 +46,6 @@ export interface ModalManager { githubImportOpen: boolean; usageOpen: boolean; usageAnchorRect: DOMRect | null; - systemStatsOpen: boolean; terminalOpen: boolean; terminalInitialCommand: string | undefined; terminalInitialCommandGeneration: number; @@ -108,9 +107,6 @@ export interface ModalManager { openUsage: (anchorRect?: DOMRect | null) => void; closeUsage: () => void; - openSystemStats: () => void; - closeSystemStats: () => void; - toggleTerminal: () => void; closeTerminal: () => void; @@ -180,7 +176,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager { const [githubImportOpen, setGitHubImportOpen] = useState(false); const [usageOpen, setUsageOpen] = useState(false); const [usageAnchorRect, setUsageAnchorRect] = useState(null); - const [systemStatsOpen, setSystemStatsOpen] = useState(false); const [terminalOpen, setTerminalOpen] = useState(false); const [terminalInitialCommand, setTerminalInitialCommand] = useState(undefined); const [terminalInitialCommandGeneration, setTerminalInitialCommandGeneration] = useState(0); @@ -215,7 +210,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager { scriptsOpen || agentsOpen || usageOpen || - systemStatsOpen || schedulesOpen || githubImportOpen || setupWizardOpen || @@ -333,9 +327,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager { setUsageAnchorRect(null); }, []); - const openSystemStats = useCallback(() => setSystemStatsOpen(true), []); - const closeSystemStats = useCallback(() => setSystemStatsOpen(false), []); - const toggleTerminal = useCallback(() => { setTerminalOpen((prev) => !prev); }, []); @@ -445,7 +436,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager { githubImportOpen, usageOpen, usageAnchorRect, - systemStatsOpen, terminalOpen, terminalInitialCommand, terminalInitialCommandGeneration, @@ -489,8 +479,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager { closeGitHubImport, openUsage, closeUsage, - openSystemStats, - closeSystemStats, toggleTerminal, closeTerminal, openFiles,