feat(FN-2644): merge fusion/fn-2644

This commit is contained in:
gsxdsm
2026-04-27 00:14:18 -07:00
parent 3fddbb73b3
commit cf12664e5a
16 changed files with 875 additions and 1 deletions

View File

@@ -785,6 +785,7 @@ function AppInner() {
activePlanningSessionCount={bgPlanningSessions.length}
onOpenUsage={modalManager.openUsage}
onOpenActivityLog={modalManager.openActivityLog}
onOpenSystemStats={modalManager.openSystemStats}
onOpenMailbox={() => handleTaskViewChange("mailbox")}
mailboxUnreadCount={mailboxUnreadCount}
onOpenSchedules={modalManager.openSchedules}
@@ -883,6 +884,7 @@ function AppInner() {
modalOpen={modalManager.anyModalOpen}
onOpenSettings={handleOpenSettings}
onOpenActivityLog={modalManager.openActivityLog}
onOpenSystemStats={modalManager.openSystemStats}
onOpenMailbox={() => handleTaskViewChange("mailbox")}
mailboxUnreadCount={mailboxUnreadCount}
onOpenGitManager={modalManager.openGitManager}

View File

@@ -4841,6 +4841,44 @@ export function fetchExecutorStats(projectId?: string): Promise<{
}>(withProjectId("/executor/stats", projectId));
}
export interface SystemStatsSnapshot {
rss: number;
heapUsed: number;
heapTotal: number;
heapLimit: number;
external: number;
arrayBuffers: number;
cpuPercent: number | null;
loadAvg: [number, number, number];
cpuCount: number;
systemTotalMem: number;
systemFreeMem: number;
pid: number;
nodeVersion: string;
platform: string;
}
export interface TaskStatsSnapshot {
total: number;
byColumn: Record<string, number>;
active: number;
agents: {
idle: number;
active: number;
running: number;
error: number;
};
}
export interface SystemStatsResponse {
systemStats: SystemStatsSnapshot;
taskStats: TaskStatsSnapshot;
}
export function fetchSystemStats(projectId?: string): Promise<SystemStatsResponse> {
return api<SystemStatsResponse>(withProjectId("/system-stats", projectId));
}
/** Fetch unified activity feed */
export function fetchActivityFeed(options?: FeedOptions): Promise<ActivityFeedEntry[]> {
const params = new URLSearchParams();

View File

@@ -16,6 +16,7 @@ import { FileBrowserModal } from "./FileBrowserModal";
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 { WorkflowStepManager } from "./WorkflowStepManager";
@@ -235,6 +236,12 @@ export function AppModals({
anchorRect={modalManager.usageAnchorRect}
/>
<SystemStatsModal
isOpen={modalManager.systemStatsOpen}
onClose={modalManager.closeSystemStats}
projectId={projectId}
/>
{modalManager.schedulesOpen && (
<ScheduledTasksModal
onClose={modalManager.closeSchedules}

View File

@@ -165,6 +165,7 @@ export interface HeaderProps {
activePlanningSessionCount?: number;
onOpenUsage?: (anchorRect?: DOMRect | null) => void;
onOpenActivityLog?: () => void;
onOpenSystemStats?: () => void;
/** Opens the mailbox view */
onOpenMailbox?: () => void;
/** Unread message count for badge display */
@@ -222,6 +223,7 @@ export function Header({
activePlanningSessionCount = 0,
onOpenUsage,
onOpenActivityLog,
onOpenSystemStats,
onOpenMailbox,
mailboxUnreadCount = 0,
onOpenSchedules,
@@ -956,6 +958,13 @@ export function Header({
</button>
)}
{/* System Stats button - desktop only */}
{!isCompact && onOpenSystemStats && (
<button className="btn-icon" onClick={onOpenSystemStats} title="System Stats" data-testid="desktop-header-system-stats-btn">
<Monitor size={16} />
</button>
)}
{/* Activity Log button - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenActivityLog && (
<button className="btn-icon" onClick={onOpenActivityLog} title="View Activity Log">

View File

@@ -43,6 +43,7 @@ export interface MobileNavBarProps {
// Navigation handlers
onOpenSettings?: () => void;
onOpenActivityLog?: () => void;
onOpenSystemStats?: () => void;
onOpenMailbox?: () => void;
mailboxUnreadCount?: number;
onOpenGitManager?: () => void;
@@ -90,6 +91,7 @@ export function MobileNavBar({
modalOpen = false,
onOpenSettings,
onOpenActivityLog,
onOpenSystemStats,
onOpenMailbox,
mailboxUnreadCount = 0,
onOpenGitManager,
@@ -343,6 +345,16 @@ export function MobileNavBar({
<span>Activity Log</span>
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-system-stats"
onClick={() => handleMoreAction(onOpenSystemStats)}
>
<Monitor />
<span>System Stats</span>
</button>
<button
type="button"
className="mobile-more-item"

View File

@@ -0,0 +1,132 @@
.system-stats-modal {
display: flex;
flex-direction: column;
gap: var(--space-md);
max-height: min(80vh, 56rem);
}
.system-stats-modal__header {
align-items: center;
}
.system-stats-modal__title {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
margin: 0;
font-size: 1rem;
}
.system-stats-modal__header-actions {
display: inline-flex;
gap: var(--space-sm);
align-items: center;
}
.system-stats-modal__state {
margin: 0 var(--space-xl);
padding: var(--space-md);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
color: var(--text-muted);
background: var(--surface);
}
.system-stats-modal__state--error,
.system-stats-modal__footer-error {
color: var(--color-error);
background: var(--status-error-bg);
border-color: var(--color-error);
}
.system-stats-modal__content {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-md);
overflow-y: auto;
padding: 0 var(--space-xl) var(--space-xl);
}
.system-stats-modal__section {
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
padding: var(--space-md);
}
.system-stats-modal__section-title {
margin: 0 0 var(--space-md);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-muted);
}
.system-stats-modal__grid {
margin: 0;
display: grid;
gap: var(--space-sm);
}
.system-stats-modal__row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-md);
}
.system-stats-modal__row dt {
margin: 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.system-stats-modal__row dd {
margin: 0;
display: inline-flex;
gap: var(--space-sm);
align-items: baseline;
text-align: right;
}
.system-stats-modal__value {
color: var(--text);
}
.system-stats-modal__detail {
color: var(--text-dim);
font-size: 0.85rem;
}
.system-stats-modal__value--warning {
color: var(--color-warning);
}
.system-stats-modal__value--critical {
color: var(--color-error);
}
.system-stats-modal__footer-error {
margin: 0 var(--space-xl) var(--space-xl);
padding: var(--space-sm) var(--space-md);
border: var(--btn-border-width) solid var(--color-error);
border-radius: var(--radius-md);
font-size: 0.85rem;
}
@media (max-width: 768px) {
.system-stats-modal {
max-height: min(84vh, 60rem);
}
.system-stats-modal__content {
grid-template-columns: minmax(0, 1fr);
padding: 0 var(--space-lg) var(--space-lg);
}
.system-stats-modal__state,
.system-stats-modal__footer-error {
margin-left: var(--space-lg);
margin-right: var(--space-lg);
}
}

View File

@@ -0,0 +1,273 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Monitor, RefreshCw, X } from "lucide-react";
import { fetchSystemStats, type SystemStatsResponse } from "../api";
import "./SystemStatsModal.css";
interface SystemStatsModalProps {
isOpen: boolean;
onClose: () => void;
projectId?: string;
}
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)}%`;
}
type Severity = "normal" | "warning" | "critical";
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 severityClassName(severity: Severity): string {
if (severity === "critical") return "system-stats-modal__value--critical";
if (severity === "warning") return "system-stats-modal__value--warning";
return "";
}
/**
* SystemStatsModal groups dashboard runtime telemetry into five sections:
* process memory metrics, CPU/load information, host memory usage, task counts
* by column, and agent state counts.
*/
export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModalProps) {
const [stats, setStats] = useState<SystemStatsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadStats = useCallback(async () => {
setLoading(true);
try {
const response = await fetchSystemStats(projectId);
setStats(response);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load system stats");
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
if (!isOpen) return;
void loadStats();
const timer = window.setInterval(() => {
void loadStats();
}, 5_000);
return () => {
window.clearInterval(timer);
};
}, [isOpen, loadStats]);
useEffect(() => {
if (!isOpen) return;
const onKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", onKeydown);
return () => document.removeEventListener("keydown", onKeydown);
}, [isOpen, onClose]);
const processRows = useMemo(() => {
if (!stats) return [];
const system = stats.systemStats;
const heapClassName = severityClassName(heapSeverity(system.heapUsed, system.heapLimit));
const rssClassName = severityClassName(rssSeverity(system.rss, system.systemTotalMem));
return [
{ label: "RSS", value: formatBytes(system.rss), detail: toPercent(system.rss, system.systemTotalMem), className: rssClassName },
{ label: "Heap Used", value: formatBytes(system.heapUsed), detail: `of ${formatBytes(system.heapTotal)}`, className: heapClassName },
{ label: "Heap Limit", value: formatBytes(system.heapLimit), detail: "V8 limit" },
{ label: "External", value: formatBytes(system.external) },
{ label: "Array Buffers", value: formatBytes(system.arrayBuffers) },
];
}, [stats]);
if (!isOpen) return null;
const system = stats?.systemStats;
const taskStats = stats?.taskStats;
const usedSystemMem = system ? system.systemTotalMem - system.systemFreeMem : 0;
const usedSystemClassName = system
? severityClassName(systemMemSeverity(usedSystemMem, system.systemTotalMem))
: "";
return (
<div
className="modal-overlay open"
role="dialog"
aria-modal="true"
aria-labelledby="system-stats-modal-title"
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
data-testid="system-stats-modal-overlay"
>
<div className="modal modal-lg system-stats-modal" data-testid="system-stats-modal">
<div className="modal-header system-stats-modal__header">
<h2 id="system-stats-modal-title" className="system-stats-modal__title">
<Monitor />
<span>System Stats</span>
</h2>
<div className="system-stats-modal__header-actions">
<button
type="button"
className="btn btn-icon"
onClick={() => void loadStats()}
title="Refresh"
aria-label="Refresh system stats"
>
<RefreshCw />
</button>
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
<X />
</button>
</div>
</div>
{loading && !stats && <div className="system-stats-modal__state">Loading system stats</div>}
{error && !stats && (
<div className="system-stats-modal__state system-stats-modal__state--error" role="alert">
{error}
</div>
)}
{stats && (
<div className="system-stats-modal__content">
<section className="system-stats-modal__section" aria-label="Process stats">
<h3 className="system-stats-modal__section-title">Process</h3>
<dl className="system-stats-modal__grid">
{processRows.map((row) => (
<div key={row.label} className="system-stats-modal__row">
<dt>{row.label}</dt>
<dd>
<span className={`system-stats-modal__value ${row.className}`.trim()}>{row.value}</span>
{row.detail ? <span className="system-stats-modal__detail">{row.detail}</span> : null}
</dd>
</div>
))}
</dl>
</section>
<section className="system-stats-modal__section" aria-label="CPU and load stats">
<h3 className="system-stats-modal__section-title">CPU &amp; Load</h3>
<dl className="system-stats-modal__grid">
<div className="system-stats-modal__row">
<dt>Load Avg</dt>
<dd>{system?.loadAvg.map((value) => value.toFixed(2)).join(" ") ?? "—"}</dd>
</div>
<div className="system-stats-modal__row">
<dt>Cores</dt>
<dd>{system?.cpuCount ?? "—"}</dd>
</div>
<div className="system-stats-modal__row">
<dt>Platform</dt>
<dd>{system?.platform ?? "—"}</dd>
</div>
<div className="system-stats-modal__row">
<dt>Node</dt>
<dd>{system?.nodeVersion ?? "—"}</dd>
</div>
<div className="system-stats-modal__row">
<dt>PID</dt>
<dd>{system?.pid ?? "—"}</dd>
</div>
</dl>
</section>
<section className="system-stats-modal__section" aria-label="System memory stats">
<h3 className="system-stats-modal__section-title">System</h3>
<dl className="system-stats-modal__grid">
<div className="system-stats-modal__row">
<dt>Memory Used</dt>
<dd>
<span className={`system-stats-modal__value ${usedSystemClassName}`.trim()}>
{system ? formatBytes(usedSystemMem) : "—"}
</span>
<span className="system-stats-modal__detail">
{system ? `${toPercent(usedSystemMem, system.systemTotalMem)} of ${formatBytes(system.systemTotalMem)}` : ""}
</span>
</dd>
</div>
<div className="system-stats-modal__row">
<dt>Memory Free</dt>
<dd>{system ? formatBytes(system.systemFreeMem) : "—"}</dd>
</div>
</dl>
</section>
<section className="system-stats-modal__section" aria-label="Task stats">
<h3 className="system-stats-modal__section-title">Tasks</h3>
<dl className="system-stats-modal__grid">
<div className="system-stats-modal__row">
<dt>Total</dt>
<dd>{taskStats?.total ?? 0}</dd>
</div>
{Object.entries(taskStats?.byColumn ?? {}).map(([column, count]) => (
<div key={column} className="system-stats-modal__row">
<dt>{column}</dt>
<dd>{count}</dd>
</div>
))}
</dl>
</section>
<section className="system-stats-modal__section" aria-label="Agent stats">
<h3 className="system-stats-modal__section-title">Agents</h3>
<dl className="system-stats-modal__grid">
<div className="system-stats-modal__row">
<dt>idle</dt>
<dd>{taskStats?.agents.idle ?? 0}</dd>
</div>
<div className="system-stats-modal__row">
<dt>active</dt>
<dd>{taskStats?.agents.active ?? 0}</dd>
</div>
<div className="system-stats-modal__row">
<dt>running</dt>
<dd>{taskStats?.agents.running ?? 0}</dd>
</div>
<div className="system-stats-modal__row">
<dt>error</dt>
<dd>{taskStats?.agents.error ?? 0}</dd>
</div>
</dl>
</section>
</div>
)}
{error && stats && <div className="system-stats-modal__footer-error">Latest refresh failed: {error}</div>}
</div>
</div>
);
}

View File

@@ -58,6 +58,14 @@ vi.mock("../NewTaskModal", () => ({
NewTaskModal: () => null,
}));
const mockSystemStatsModalProps = vi.fn();
vi.mock("../SystemStatsModal", () => ({
SystemStatsModal: (props: any) => {
mockSystemStatsModalProps(props);
return null;
},
}));
vi.mock("../ActivityLogModal", () => ({
ActivityLogModal: () => null,
}));
@@ -133,6 +141,7 @@ describe("AppModals", () => {
filesOpen: false,
fileBrowserWorkspace: "project",
usageOpen: false,
systemStatsOpen: false,
schedulesOpen: false,
newTaskModalOpen: false,
activityLogOpen: false,
@@ -159,6 +168,8 @@ 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(),
@@ -194,6 +205,7 @@ describe("AppModals", () => {
mockScheduledTasksModalProps.mockClear();
mockModelOnboardingModalProps.mockClear();
mockSettingsModalProps.mockClear();
mockSystemStatsModalProps.mockClear();
});
it("renders without crashing", () => {
@@ -344,4 +356,40 @@ describe("AppModals", () => {
expect(mockScheduledTasksModalProps.mock.calls[0][0].projectId).toBe(expected);
});
});
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(
<AppModals
{...commonProps}
projectId="proj-system"
addToast={vi.fn()}
modalManager={{ ...mockModalManager, systemStatsOpen: true, closeSystemStats }}
/>,
);
expect(mockSystemStatsModalProps).toHaveBeenCalledTimes(1);
expect(mockSystemStatsModalProps).toHaveBeenCalledWith(
expect.objectContaining({
isOpen: true,
onClose: closeSystemStats,
projectId: "proj-system",
}),
);
});
});
});

View File

@@ -69,6 +69,18 @@ describe("Header", () => {
expect(screen.getByTitle("Settings")).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 });

View File

@@ -32,6 +32,7 @@ const createDefaultProps = () => ({
modalOpen: false,
onOpenSettings: vi.fn(),
onOpenActivityLog: vi.fn(),
onOpenSystemStats: vi.fn(),
onOpenMailbox: vi.fn(),
mailboxUnreadCount: 0,
onOpenGitManager: vi.fn(),
@@ -232,6 +233,7 @@ 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();
@@ -293,6 +295,17 @@ describe("MobileNavBar", () => {
expect(props.onOpenActivityLog).toHaveBeenCalledOnce();
});
it("system stats item in more sheet calls onOpenSystemStats", () => {
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} />);
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(<MobileNavBar {...props} />);

View File

@@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import { SystemStatsModal } from "../SystemStatsModal";
vi.mock("lucide-react", () => ({
Monitor: () => <span data-testid="icon-monitor" />,
RefreshCw: () => <span data-testid="icon-refresh" />,
X: () => <span data-testid="icon-x" />,
}));
const mockFetchSystemStats = vi.fn();
vi.mock("../../api", () => ({
fetchSystemStats: (...args: unknown[]) => mockFetchSystemStats(...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: null,
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,
},
},
};
describe("SystemStatsModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it("shows loading state while initial stats are fetched", async () => {
mockFetchSystemStats.mockReturnValue(new Promise(() => undefined));
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
expect(await screen.findByText("Loading system stats…")).toBeDefined();
});
it("renders fetched metrics across all sections", async () => {
mockFetchSystemStats.mockResolvedValue(sampleStats);
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} projectId="proj-1" />);
await waitFor(() => {
expect(mockFetchSystemStats).toHaveBeenCalledWith("proj-1");
});
expect(screen.getByText("System Stats")).toBeDefined();
expect(screen.getByText("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("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();
expect(screen.getByText("1.20 0.80 0.50")).toBeDefined();
const criticalValues = document.querySelectorAll(".system-stats-modal__value--critical");
expect(criticalValues.length).toBeGreaterThan(0);
});
it("shows error state when initial fetch fails", async () => {
mockFetchSystemStats.mockRejectedValue(new Error("stats unavailable"));
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
expect(await screen.findByRole("alert")).toHaveTextContent("stats unavailable");
});
it("refreshes every 5 seconds while open and stops when closed", async () => {
vi.useFakeTimers();
mockFetchSystemStats.mockResolvedValue(sampleStats);
const { rerender } = render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
await act(async () => {
await Promise.resolve();
});
expect(mockFetchSystemStats).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});
expect(mockFetchSystemStats).toHaveBeenCalledTimes(2);
rerender(<SystemStatsModal isOpen={false} onClose={vi.fn()} />);
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(mockFetchSystemStats).toHaveBeenCalledTimes(2);
});
});

View File

@@ -165,6 +165,29 @@ 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(() =>

View File

@@ -37,6 +37,7 @@ export interface ModalManager {
githubImportOpen: boolean;
usageOpen: boolean;
usageAnchorRect: DOMRect | null;
systemStatsOpen: boolean;
terminalOpen: boolean;
terminalInitialCommand: string | undefined;
filesOpen: boolean;
@@ -81,6 +82,9 @@ export interface ModalManager {
openUsage: (anchorRect?: DOMRect | null) => void;
closeUsage: () => void;
openSystemStats: () => void;
closeSystemStats: () => void;
toggleTerminal: () => void;
closeTerminal: () => void;
@@ -140,6 +144,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const [githubImportOpen, setGitHubImportOpen] = useState(false);
const [usageOpen, setUsageOpen] = useState(false);
const [usageAnchorRect, setUsageAnchorRect] = useState<DOMRect | null>(null);
const [systemStatsOpen, setSystemStatsOpen] = useState(false);
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
const [filesOpen, setFilesOpen] = useState(false);
@@ -166,6 +171,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
scriptsOpen ||
agentsOpen ||
usageOpen ||
systemStatsOpen ||
schedulesOpen ||
githubImportOpen ||
setupWizardOpen ||
@@ -249,6 +255,9 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
setUsageAnchorRect(null);
}, []);
const openSystemStats = useCallback(() => setSystemStatsOpen(true), []);
const closeSystemStats = useCallback(() => setSystemStatsOpen(false), []);
const toggleTerminal = useCallback(() => {
setTerminalOpen((prev) => !prev);
}, []);
@@ -325,6 +334,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
githubImportOpen,
usageOpen,
usageAnchorRect,
systemStatsOpen,
terminalOpen,
terminalInitialCommand,
filesOpen,
@@ -359,6 +369,8 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
closeGitHubImport,
openUsage,
closeUsage,
openSystemStats,
closeSystemStats,
toggleTerminal,
closeTerminal,
openFiles,