From 7c4242451e3eb8f30e31029c248a950e851a3bb4 Mon Sep 17 00:00:00 2001 From: Fusion Date: Wed, 29 Apr 2026 23:52:20 -0700 Subject: [PATCH] feat(FN-3007): merge fusion/fn-3007 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commits merged: - feat(FN-3007): complete Step 4 — document system memory usage bar - fix(FN-3007): restore custom provider mapper type compatibility - test(FN-3007): complete Step 2 — cover memory bar semantics and severity - feat(FN-3007): complete Step 1 — add semantic memory usage progressbar - test(FN-3007): complete Step 2 — cover memory usage progress semantics - feat(FN-3007): complete Step 1 — add system memory usage bar Files changed: docs/architecture.md | 2 +- .../app/components/ModelOnboardingModal.tsx | 27 ++++++------ .../dashboard/app/components/SettingsModal.tsx | 27 ++++++------ .../dashboard/app/components/SystemStatsModal.css | 48 ++++++++++++++++++++++ .../dashboard/app/components/SystemStatsModal.tsx | 29 +++++++++++-- .../components/__tests__/SystemStatsModal.test.tsx | 33 +++++++++++++++ 6 files changed, 133 insertions(+), 33 deletions(-) Fusion-Task-Id: FN-3007 --- docs/architecture.md | 2 +- .../app/components/ModelOnboardingModal.tsx | 27 +++++------ .../app/components/SettingsModal.tsx | 27 +++++------ .../app/components/SystemStatsModal.css | 48 +++++++++++++++++++ .../app/components/SystemStatsModal.tsx | 29 +++++++++-- .../__tests__/SystemStatsModal.test.tsx | 33 +++++++++++++ 6 files changed, 133 insertions(+), 33 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index bb51f732c..fc672a1a3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -402,7 +402,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 plus task/agent aggregates and manual vitest process termination for the System Stats modal +- System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry (including host memory rendered as both numeric values and a visual usage bar in the System Stats modal), 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` and `/api/remote/tunnel/stop` are the only lifecycle transition endpoints. diff --git a/packages/dashboard/app/components/ModelOnboardingModal.tsx b/packages/dashboard/app/components/ModelOnboardingModal.tsx index 887bc7d7f..d916c3784 100644 --- a/packages/dashboard/app/components/ModelOnboardingModal.tsx +++ b/packages/dashboard/app/components/ModelOnboardingModal.tsx @@ -27,20 +27,19 @@ import { appendTokenQuery } from "../auth"; const mapLegacyCustomProviderToConfig = ( provider: CustomProvider | CustomProviderConfig, -): CustomProviderConfig => { - if ("api" in provider) { - return provider; - } - - return { - id: provider.id, - name: provider.name, - baseUrl: provider.baseUrl, - api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-responses", - apiKey: provider.apiKey, - models: provider.models?.map((model) => ({ id: model.id, name: model.name })) ?? [], - }; -}; +): CustomProviderConfig => ({ + id: provider.id, + name: provider.name, + baseUrl: provider.baseUrl, + api: + "api" in provider + ? provider.api + : provider.apiType === "anthropic-compatible" + ? "anthropic-messages" + : "openai-responses", + apiKey: provider.apiKey, + models: provider.models?.map((model) => ({ id: model.id, name: model.name })) ?? [], +}); /** Provider-specific API key setup metadata for onboarding form rendering */ interface ApiKeyInfo { diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index e08c42471..95523fc31 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -48,20 +48,19 @@ const GITHUB_STAR_CLICKED_KEY = "fusion:github-star-clicked"; const mapLegacyCustomProviderToConfig = ( provider: CustomProvider | CustomProviderConfig, -): CustomProviderConfig => { - if ("api" in provider) { - return provider; - } - - return { - id: provider.id, - name: provider.name, - baseUrl: provider.baseUrl, - api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-responses", - apiKey: provider.apiKey, - models: provider.models?.map((model) => ({ id: model.id, name: model.name })) ?? [], - }; -}; +): CustomProviderConfig => ({ + id: provider.id, + name: provider.name, + baseUrl: provider.baseUrl, + api: + "api" in provider + ? provider.api + : provider.apiType === "anthropic-compatible" + ? "anthropic-messages" + : "openai-responses", + apiKey: provider.apiKey, + models: provider.models?.map((model) => ({ id: model.id, name: model.name })) ?? [], +}); function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error"): string { if (status === "online") return "Online"; diff --git a/packages/dashboard/app/components/SystemStatsModal.css b/packages/dashboard/app/components/SystemStatsModal.css index a8d3ee83d..5f8db5c5c 100644 --- a/packages/dashboard/app/components/SystemStatsModal.css +++ b/packages/dashboard/app/components/SystemStatsModal.css @@ -113,6 +113,54 @@ gap: var(--space-md); } +.system-stats-modal__row--memory-used { + align-items: stretch; + flex-direction: column; +} + +.system-stats-modal__row--memory-used dd { + width: 100%; + justify-content: flex-end; +} + +.system-stats-modal__memory-progress-wrapper { + width: 100%; +} + +.system-stats-modal__memory-progress-track { + width: 100%; + height: var(--space-sm); + border-radius: var(--radius-sm); + background: var(--border); + overflow: hidden; +} + +.system-stats-modal__memory-progress-track--warning { + box-shadow: inset 0 0 0 var(--btn-border-width) var(--color-warning); +} + +.system-stats-modal__memory-progress-track--critical { + box-shadow: inset 0 0 0 var(--btn-border-width) var(--color-error); +} + +.system-stats-modal__memory-progress-fill { + height: 100%; + border-radius: var(--radius-sm); + transition: width var(--transition-normal), background-color var(--transition-normal); +} + +.system-stats-modal__memory-progress-fill--normal { + background: var(--color-success); +} + +.system-stats-modal__memory-progress-fill--warning { + background: var(--color-warning); +} + +.system-stats-modal__memory-progress-fill--critical { + background: var(--color-error); +} + .system-stats-modal__row dt { margin: 0; font-size: 0.85rem; diff --git a/packages/dashboard/app/components/SystemStatsModal.tsx b/packages/dashboard/app/components/SystemStatsModal.tsx index c102535ca..e8798f938 100644 --- a/packages/dashboard/app/components/SystemStatsModal.tsx +++ b/packages/dashboard/app/components/SystemStatsModal.tsx @@ -209,9 +209,15 @@ export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModa : "Waiting for first update"; const taskStats = stats?.taskStats; const usedSystemMem = system ? system.systemTotalMem - system.systemFreeMem : 0; - const usedSystemClassName = system - ? severityClassName(systemMemSeverity(usedSystemMem, system.systemTotalMem)) - : ""; + const usedSystemMemSeverity = system ? systemMemSeverity(usedSystemMem, system.systemTotalMem) : "normal"; + const usedSystemClassName = system ? severityClassName(usedSystemMemSeverity) : ""; + const usedSystemMemPercent = + system && Number.isFinite(system.systemTotalMem) && system.systemTotalMem > 0 + ? Math.max(0, Math.min(100, (usedSystemMem / system.systemTotalMem) * 100)) + : 0; + const usedSystemMemProgressLabel = system + ? `System memory used: ${usedSystemMemPercent.toFixed(1)}% (${formatBytes(usedSystemMem)} of ${formatBytes(system.systemTotalMem)})` + : "System memory usage unavailable"; const vitestProcessCount = stats?.vitestProcessCount; const killResultClassName = killResult ? killResult.killed > 0 @@ -314,7 +320,7 @@ export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModa

System

-
+
Memory Used
@@ -324,6 +330,21 @@ export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModa {system ? `${toPercent(usedSystemMem, system.systemTotalMem)} of ${formatBytes(system.systemTotalMem)}` : ""}
+
+
+
+
+
Memory Free
diff --git a/packages/dashboard/app/components/__tests__/SystemStatsModal.test.tsx b/packages/dashboard/app/components/__tests__/SystemStatsModal.test.tsx index 3d5eac030..ceaca6543 100644 --- a/packages/dashboard/app/components/__tests__/SystemStatsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SystemStatsModal.test.tsx @@ -105,6 +105,17 @@ describe("SystemStatsModal", () => { 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("1.20 0.80 0.50")).toBeDefined(); expect(screen.getByText("Vitest Processes")).toBeDefined(); expect(screen.getByText(/Last auto-kill:/)).toBeDefined(); @@ -113,6 +124,28 @@ describe("SystemStatsModal", () => { 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();