feat(FN-2644): merge fusion/fn-2644
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
132
packages/dashboard/app/components/SystemStatsModal.css
Normal file
132
packages/dashboard/app/components/SystemStatsModal.css
Normal 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);
|
||||
}
|
||||
}
|
||||
273
packages/dashboard/app/components/SystemStatsModal.tsx
Normal file
273
packages/dashboard/app/components/SystemStatsModal.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user