feat(FN-2644): merge fusion/fn-2644
This commit is contained in:
@@ -396,6 +396,7 @@ Operator setup + troubleshooting guide: **[Remote Access runbook](./remote-acces
|
|||||||
|
|
||||||
Key server capabilities:
|
Key server capabilities:
|
||||||
- REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings
|
- REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings
|
||||||
|
- System stats snapshot API (`GET /api/system-stats`) exposing dashboard process/system telemetry plus task and agent aggregates for the System Stats modal
|
||||||
- Remote access APIs (`/api/remote/*`) for provider config, activation, tunnel lifecycle, status, token issuance, authenticated URL generation, and QR payload generation
|
- 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)
|
- 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.
|
- `/api/remote/tunnel/start` and `/api/remote/tunnel/stop` are the only lifecycle transition endpoints.
|
||||||
|
|||||||
@@ -785,6 +785,7 @@ function AppInner() {
|
|||||||
activePlanningSessionCount={bgPlanningSessions.length}
|
activePlanningSessionCount={bgPlanningSessions.length}
|
||||||
onOpenUsage={modalManager.openUsage}
|
onOpenUsage={modalManager.openUsage}
|
||||||
onOpenActivityLog={modalManager.openActivityLog}
|
onOpenActivityLog={modalManager.openActivityLog}
|
||||||
|
onOpenSystemStats={modalManager.openSystemStats}
|
||||||
onOpenMailbox={() => handleTaskViewChange("mailbox")}
|
onOpenMailbox={() => handleTaskViewChange("mailbox")}
|
||||||
mailboxUnreadCount={mailboxUnreadCount}
|
mailboxUnreadCount={mailboxUnreadCount}
|
||||||
onOpenSchedules={modalManager.openSchedules}
|
onOpenSchedules={modalManager.openSchedules}
|
||||||
@@ -883,6 +884,7 @@ function AppInner() {
|
|||||||
modalOpen={modalManager.anyModalOpen}
|
modalOpen={modalManager.anyModalOpen}
|
||||||
onOpenSettings={handleOpenSettings}
|
onOpenSettings={handleOpenSettings}
|
||||||
onOpenActivityLog={modalManager.openActivityLog}
|
onOpenActivityLog={modalManager.openActivityLog}
|
||||||
|
onOpenSystemStats={modalManager.openSystemStats}
|
||||||
onOpenMailbox={() => handleTaskViewChange("mailbox")}
|
onOpenMailbox={() => handleTaskViewChange("mailbox")}
|
||||||
mailboxUnreadCount={mailboxUnreadCount}
|
mailboxUnreadCount={mailboxUnreadCount}
|
||||||
onOpenGitManager={modalManager.openGitManager}
|
onOpenGitManager={modalManager.openGitManager}
|
||||||
|
|||||||
@@ -4841,6 +4841,44 @@ export function fetchExecutorStats(projectId?: string): Promise<{
|
|||||||
}>(withProjectId("/executor/stats", projectId));
|
}>(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 */
|
/** Fetch unified activity feed */
|
||||||
export function fetchActivityFeed(options?: FeedOptions): Promise<ActivityFeedEntry[]> {
|
export function fetchActivityFeed(options?: FeedOptions): Promise<ActivityFeedEntry[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { FileBrowserModal } from "./FileBrowserModal";
|
|||||||
import { UsageIndicator } from "./UsageIndicator";
|
import { UsageIndicator } from "./UsageIndicator";
|
||||||
import { ScheduledTasksModal } from "./ScheduledTasksModal";
|
import { ScheduledTasksModal } from "./ScheduledTasksModal";
|
||||||
import { NewTaskModal } from "./NewTaskModal";
|
import { NewTaskModal } from "./NewTaskModal";
|
||||||
|
import { SystemStatsModal } from "./SystemStatsModal";
|
||||||
import { ActivityLogModal } from "./ActivityLogModal";
|
import { ActivityLogModal } from "./ActivityLogModal";
|
||||||
import { GitManagerModal } from "./GitManagerModal";
|
import { GitManagerModal } from "./GitManagerModal";
|
||||||
import { WorkflowStepManager } from "./WorkflowStepManager";
|
import { WorkflowStepManager } from "./WorkflowStepManager";
|
||||||
@@ -235,6 +236,12 @@ export function AppModals({
|
|||||||
anchorRect={modalManager.usageAnchorRect}
|
anchorRect={modalManager.usageAnchorRect}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SystemStatsModal
|
||||||
|
isOpen={modalManager.systemStatsOpen}
|
||||||
|
onClose={modalManager.closeSystemStats}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
|
||||||
{modalManager.schedulesOpen && (
|
{modalManager.schedulesOpen && (
|
||||||
<ScheduledTasksModal
|
<ScheduledTasksModal
|
||||||
onClose={modalManager.closeSchedules}
|
onClose={modalManager.closeSchedules}
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ export interface HeaderProps {
|
|||||||
activePlanningSessionCount?: number;
|
activePlanningSessionCount?: number;
|
||||||
onOpenUsage?: (anchorRect?: DOMRect | null) => void;
|
onOpenUsage?: (anchorRect?: DOMRect | null) => void;
|
||||||
onOpenActivityLog?: () => void;
|
onOpenActivityLog?: () => void;
|
||||||
|
onOpenSystemStats?: () => void;
|
||||||
/** Opens the mailbox view */
|
/** Opens the mailbox view */
|
||||||
onOpenMailbox?: () => void;
|
onOpenMailbox?: () => void;
|
||||||
/** Unread message count for badge display */
|
/** Unread message count for badge display */
|
||||||
@@ -222,6 +223,7 @@ export function Header({
|
|||||||
activePlanningSessionCount = 0,
|
activePlanningSessionCount = 0,
|
||||||
onOpenUsage,
|
onOpenUsage,
|
||||||
onOpenActivityLog,
|
onOpenActivityLog,
|
||||||
|
onOpenSystemStats,
|
||||||
onOpenMailbox,
|
onOpenMailbox,
|
||||||
mailboxUnreadCount = 0,
|
mailboxUnreadCount = 0,
|
||||||
onOpenSchedules,
|
onOpenSchedules,
|
||||||
@@ -956,6 +958,13 @@ export function Header({
|
|||||||
</button>
|
</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) */}
|
{/* Activity Log button - desktop only (moved to overflow on mobile/tablet) */}
|
||||||
{!isCompact && onOpenActivityLog && (
|
{!isCompact && onOpenActivityLog && (
|
||||||
<button className="btn-icon" onClick={onOpenActivityLog} title="View Activity Log">
|
<button className="btn-icon" onClick={onOpenActivityLog} title="View Activity Log">
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface MobileNavBarProps {
|
|||||||
// Navigation handlers
|
// Navigation handlers
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
onOpenActivityLog?: () => void;
|
onOpenActivityLog?: () => void;
|
||||||
|
onOpenSystemStats?: () => void;
|
||||||
onOpenMailbox?: () => void;
|
onOpenMailbox?: () => void;
|
||||||
mailboxUnreadCount?: number;
|
mailboxUnreadCount?: number;
|
||||||
onOpenGitManager?: () => void;
|
onOpenGitManager?: () => void;
|
||||||
@@ -90,6 +91,7 @@ export function MobileNavBar({
|
|||||||
modalOpen = false,
|
modalOpen = false,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onOpenActivityLog,
|
onOpenActivityLog,
|
||||||
|
onOpenSystemStats,
|
||||||
onOpenMailbox,
|
onOpenMailbox,
|
||||||
mailboxUnreadCount = 0,
|
mailboxUnreadCount = 0,
|
||||||
onOpenGitManager,
|
onOpenGitManager,
|
||||||
@@ -343,6 +345,16 @@ export function MobileNavBar({
|
|||||||
<span>Activity Log</span>
|
<span>Activity Log</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mobile-more-item"
|
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,
|
NewTaskModal: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockSystemStatsModalProps = vi.fn();
|
||||||
|
vi.mock("../SystemStatsModal", () => ({
|
||||||
|
SystemStatsModal: (props: any) => {
|
||||||
|
mockSystemStatsModalProps(props);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../ActivityLogModal", () => ({
|
vi.mock("../ActivityLogModal", () => ({
|
||||||
ActivityLogModal: () => null,
|
ActivityLogModal: () => null,
|
||||||
}));
|
}));
|
||||||
@@ -133,6 +141,7 @@ describe("AppModals", () => {
|
|||||||
filesOpen: false,
|
filesOpen: false,
|
||||||
fileBrowserWorkspace: "project",
|
fileBrowserWorkspace: "project",
|
||||||
usageOpen: false,
|
usageOpen: false,
|
||||||
|
systemStatsOpen: false,
|
||||||
schedulesOpen: false,
|
schedulesOpen: false,
|
||||||
newTaskModalOpen: false,
|
newTaskModalOpen: false,
|
||||||
activityLogOpen: false,
|
activityLogOpen: false,
|
||||||
@@ -159,6 +168,8 @@ describe("AppModals", () => {
|
|||||||
setFileWorkspace: vi.fn(),
|
setFileWorkspace: vi.fn(),
|
||||||
openUsage: vi.fn(),
|
openUsage: vi.fn(),
|
||||||
closeUsage: vi.fn(),
|
closeUsage: vi.fn(),
|
||||||
|
openSystemStats: vi.fn(),
|
||||||
|
closeSystemStats: vi.fn(),
|
||||||
openSchedules: vi.fn(),
|
openSchedules: vi.fn(),
|
||||||
closeSchedules: vi.fn(),
|
closeSchedules: vi.fn(),
|
||||||
openNewTask: vi.fn(),
|
openNewTask: vi.fn(),
|
||||||
@@ -194,6 +205,7 @@ describe("AppModals", () => {
|
|||||||
mockScheduledTasksModalProps.mockClear();
|
mockScheduledTasksModalProps.mockClear();
|
||||||
mockModelOnboardingModalProps.mockClear();
|
mockModelOnboardingModalProps.mockClear();
|
||||||
mockSettingsModalProps.mockClear();
|
mockSettingsModalProps.mockClear();
|
||||||
|
mockSystemStatsModalProps.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders without crashing", () => {
|
it("renders without crashing", () => {
|
||||||
@@ -344,4 +356,40 @@ describe("AppModals", () => {
|
|||||||
expect(mockScheduledTasksModalProps.mock.calls[0][0].projectId).toBe(expected);
|
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();
|
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", () => {
|
it("calls onOpenSettings when settings button is clicked", () => {
|
||||||
const onOpenSettings = vi.fn();
|
const onOpenSettings = vi.fn();
|
||||||
renderHeader({ onOpenSettings });
|
renderHeader({ onOpenSettings });
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const createDefaultProps = () => ({
|
|||||||
modalOpen: false,
|
modalOpen: false,
|
||||||
onOpenSettings: vi.fn(),
|
onOpenSettings: vi.fn(),
|
||||||
onOpenActivityLog: vi.fn(),
|
onOpenActivityLog: vi.fn(),
|
||||||
|
onOpenSystemStats: vi.fn(),
|
||||||
onOpenMailbox: vi.fn(),
|
onOpenMailbox: vi.fn(),
|
||||||
mailboxUnreadCount: 0,
|
mailboxUnreadCount: 0,
|
||||||
onOpenGitManager: vi.fn(),
|
onOpenGitManager: vi.fn(),
|
||||||
@@ -232,6 +233,7 @@ describe("MobileNavBar", () => {
|
|||||||
|
|
||||||
expect(screen.getByTestId("mobile-more-item-mailbox")).toBeDefined();
|
expect(screen.getByTestId("mobile-more-item-mailbox")).toBeDefined();
|
||||||
expect(screen.getByTestId("mobile-more-item-activity")).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-git")).toBeDefined();
|
||||||
expect(screen.getByTestId("mobile-more-item-terminal")).toBeDefined();
|
expect(screen.getByTestId("mobile-more-item-terminal")).toBeDefined();
|
||||||
expect(screen.getByTestId("mobile-more-item-files")).toBeDefined();
|
expect(screen.getByTestId("mobile-more-item-files")).toBeDefined();
|
||||||
@@ -293,6 +295,17 @@ describe("MobileNavBar", () => {
|
|||||||
expect(props.onOpenActivityLog).toHaveBeenCalledOnce();
|
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", () => {
|
it("closes sheet and calls handler when item is clicked", () => {
|
||||||
const props = createDefaultProps();
|
const props = createDefaultProps();
|
||||||
const { container } = render(<MobileNavBar {...props} />);
|
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();
|
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", () => {
|
it("accepts plain Task object for optimistic modal opening", () => {
|
||||||
const task = createTask("FN-456");
|
const task = createTask("FN-456");
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface ModalManager {
|
|||||||
githubImportOpen: boolean;
|
githubImportOpen: boolean;
|
||||||
usageOpen: boolean;
|
usageOpen: boolean;
|
||||||
usageAnchorRect: DOMRect | null;
|
usageAnchorRect: DOMRect | null;
|
||||||
|
systemStatsOpen: boolean;
|
||||||
terminalOpen: boolean;
|
terminalOpen: boolean;
|
||||||
terminalInitialCommand: string | undefined;
|
terminalInitialCommand: string | undefined;
|
||||||
filesOpen: boolean;
|
filesOpen: boolean;
|
||||||
@@ -81,6 +82,9 @@ export interface ModalManager {
|
|||||||
openUsage: (anchorRect?: DOMRect | null) => void;
|
openUsage: (anchorRect?: DOMRect | null) => void;
|
||||||
closeUsage: () => void;
|
closeUsage: () => void;
|
||||||
|
|
||||||
|
openSystemStats: () => void;
|
||||||
|
closeSystemStats: () => void;
|
||||||
|
|
||||||
toggleTerminal: () => void;
|
toggleTerminal: () => void;
|
||||||
closeTerminal: () => void;
|
closeTerminal: () => void;
|
||||||
|
|
||||||
@@ -140,6 +144,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
const [githubImportOpen, setGitHubImportOpen] = useState(false);
|
const [githubImportOpen, setGitHubImportOpen] = useState(false);
|
||||||
const [usageOpen, setUsageOpen] = useState(false);
|
const [usageOpen, setUsageOpen] = useState(false);
|
||||||
const [usageAnchorRect, setUsageAnchorRect] = useState<DOMRect | null>(null);
|
const [usageAnchorRect, setUsageAnchorRect] = useState<DOMRect | null>(null);
|
||||||
|
const [systemStatsOpen, setSystemStatsOpen] = useState(false);
|
||||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||||
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
||||||
const [filesOpen, setFilesOpen] = useState(false);
|
const [filesOpen, setFilesOpen] = useState(false);
|
||||||
@@ -166,6 +171,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
scriptsOpen ||
|
scriptsOpen ||
|
||||||
agentsOpen ||
|
agentsOpen ||
|
||||||
usageOpen ||
|
usageOpen ||
|
||||||
|
systemStatsOpen ||
|
||||||
schedulesOpen ||
|
schedulesOpen ||
|
||||||
githubImportOpen ||
|
githubImportOpen ||
|
||||||
setupWizardOpen ||
|
setupWizardOpen ||
|
||||||
@@ -249,6 +255,9 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
setUsageAnchorRect(null);
|
setUsageAnchorRect(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openSystemStats = useCallback(() => setSystemStatsOpen(true), []);
|
||||||
|
const closeSystemStats = useCallback(() => setSystemStatsOpen(false), []);
|
||||||
|
|
||||||
const toggleTerminal = useCallback(() => {
|
const toggleTerminal = useCallback(() => {
|
||||||
setTerminalOpen((prev) => !prev);
|
setTerminalOpen((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -325,6 +334,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
githubImportOpen,
|
githubImportOpen,
|
||||||
usageOpen,
|
usageOpen,
|
||||||
usageAnchorRect,
|
usageAnchorRect,
|
||||||
|
systemStatsOpen,
|
||||||
terminalOpen,
|
terminalOpen,
|
||||||
terminalInitialCommand,
|
terminalInitialCommand,
|
||||||
filesOpen,
|
filesOpen,
|
||||||
@@ -359,6 +369,8 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
closeGitHubImport,
|
closeGitHubImport,
|
||||||
openUsage,
|
openUsage,
|
||||||
closeUsage,
|
closeUsage,
|
||||||
|
openSystemStats,
|
||||||
|
closeSystemStats,
|
||||||
toggleTerminal,
|
toggleTerminal,
|
||||||
closeTerminal,
|
closeTerminal,
|
||||||
openFiles,
|
openFiles,
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ vi.mock("@fusion/engine", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { isGhAvailable, isGhAuthenticated } from "@fusion/core";
|
import { AgentStore, isGhAvailable, isGhAuthenticated } from "@fusion/core";
|
||||||
|
|
||||||
const mockIsGhAvailable = vi.mocked(isGhAvailable);
|
const mockIsGhAvailable = vi.mocked(isGhAvailable);
|
||||||
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
|
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
|
||||||
@@ -251,6 +251,99 @@ describe("route registrar ordering invariants", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /api/system-stats", () => {
|
||||||
|
const projectId = "proj-system-stats";
|
||||||
|
|
||||||
|
function buildApp(store: TaskStore, options?: Parameters<typeof createApiRoutes>[1]) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api", createApiRoutes(store, options));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns process/system metrics with task and agent aggregates", async () => {
|
||||||
|
const store = createMockStore({
|
||||||
|
listTasks: vi.fn().mockResolvedValue([
|
||||||
|
{ id: "FN-1", column: "triage" },
|
||||||
|
{ id: "FN-2", column: "in-progress" },
|
||||||
|
{ id: "FN-3", column: "in-review" },
|
||||||
|
]),
|
||||||
|
getFusionDir: vi.fn().mockReturnValue("/fake/default"),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(AgentStore.prototype, "listAgents").mockResolvedValue([
|
||||||
|
{ id: "agent-1", state: "idle" },
|
||||||
|
{ id: "agent-2", state: "active" },
|
||||||
|
{ id: "agent-3", state: "running" },
|
||||||
|
{ id: "agent-4", state: "error" },
|
||||||
|
] as Array<Awaited<ReturnType<AgentStore["listAgents"]>>[number]>);
|
||||||
|
|
||||||
|
const res = await GET(buildApp(store), "/api/system-stats");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.systemStats).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
rss: expect.any(Number),
|
||||||
|
heapUsed: expect.any(Number),
|
||||||
|
heapTotal: expect.any(Number),
|
||||||
|
heapLimit: expect.any(Number),
|
||||||
|
external: expect.any(Number),
|
||||||
|
arrayBuffers: expect.any(Number),
|
||||||
|
cpuPercent: null,
|
||||||
|
loadAvg: expect.arrayContaining([expect.any(Number)]),
|
||||||
|
cpuCount: expect.any(Number),
|
||||||
|
systemTotalMem: expect.any(Number),
|
||||||
|
systemFreeMem: expect.any(Number),
|
||||||
|
pid: expect.any(Number),
|
||||||
|
nodeVersion: expect.stringMatching(/^v/),
|
||||||
|
platform: expect.stringContaining("/"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.body.taskStats).toEqual({
|
||||||
|
total: 3,
|
||||||
|
byColumn: {
|
||||||
|
triage: 1,
|
||||||
|
todo: 0,
|
||||||
|
"in-progress": 1,
|
||||||
|
"in-review": 1,
|
||||||
|
done: 0,
|
||||||
|
archived: 0,
|
||||||
|
},
|
||||||
|
active: 2,
|
||||||
|
agents: {
|
||||||
|
idle: 1,
|
||||||
|
active: 1,
|
||||||
|
running: 1,
|
||||||
|
error: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses project-scoped store when projectId query param is provided", async () => {
|
||||||
|
const defaultStore = createMockStore({
|
||||||
|
listTasks: vi.fn().mockResolvedValue([{ id: "FN-default", column: "triage" }]),
|
||||||
|
getFusionDir: vi.fn().mockReturnValue("/fake/default"),
|
||||||
|
});
|
||||||
|
const scopedStore = createMockStore({
|
||||||
|
listTasks: vi.fn().mockResolvedValue([{ id: "FN-scoped", column: "todo" }]),
|
||||||
|
getFusionDir: vi.fn().mockReturnValue("/fake/scoped"),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore);
|
||||||
|
vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(AgentStore.prototype, "listAgents").mockResolvedValue([]);
|
||||||
|
|
||||||
|
const res = await GET(buildApp(defaultStore), `/api/system-stats?projectId=${projectId}`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
|
||||||
|
expect(scopedStore.listTasks).toHaveBeenCalledTimes(1);
|
||||||
|
expect(defaultStore.listTasks).not.toHaveBeenCalled();
|
||||||
|
expect(res.body.taskStats.byColumn.todo).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("GET /api/plugins/runtimes", () => {
|
describe("GET /api/plugins/runtimes", () => {
|
||||||
function buildApp(pluginLoader?: { getPluginRuntimes?: () => Array<{ pluginId: string; runtime: { metadata: { runtimeId: string; name: string; description?: string; version?: string }; factory: () => unknown } }> }) {
|
function buildApp(pluginLoader?: { getPluginRuntimes?: () => Array<{ pluginId: string; runtime: { metadata: { runtimeId: string; name: string; description?: string; version?: string }; factory: () => unknown } }> }) {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ declare module "express" {
|
|||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import { resolve, sep, join, isAbsolute } from "node:path";
|
import { resolve, sep, join, isAbsolute } from "node:path";
|
||||||
import * as nodeFs from "node:fs";
|
import * as nodeFs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import v8 from "node:v8";
|
||||||
|
|
||||||
import type { TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType } from "@fusion/core";
|
import type { TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType } from "@fusion/core";
|
||||||
import { type Task, type PiExtensionEntry, type PiExtensionSettings, AutomationStore, RoutineStore, isWebhookTrigger, MemoryBackendError, listAgentMemoryFiles, readAgentMemoryFile, writeAgentMemoryFile, discoverPiExtensions, getFusionAgentDir, getLegacyPiAgentDir } from "@fusion/core";
|
import { type Task, type PiExtensionEntry, type PiExtensionSettings, AutomationStore, RoutineStore, isWebhookTrigger, MemoryBackendError, listAgentMemoryFiles, readAgentMemoryFile, writeAgentMemoryFile, discoverPiExtensions, getFusionAgentDir, getLegacyPiAgentDir } from "@fusion/core";
|
||||||
@@ -1197,6 +1199,74 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/system-stats
|
||||||
|
* Returns process/system metrics plus task and agent aggregates.
|
||||||
|
*/
|
||||||
|
router.get("/system-stats", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
|
const mem = process.memoryUsage();
|
||||||
|
const heapStats = v8.getHeapStatistics();
|
||||||
|
const load = os.loadavg();
|
||||||
|
|
||||||
|
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
|
||||||
|
const byColumn: Record<string, number> = {
|
||||||
|
triage: 0,
|
||||||
|
todo: 0,
|
||||||
|
"in-progress": 0,
|
||||||
|
"in-review": 0,
|
||||||
|
done: 0,
|
||||||
|
archived: 0,
|
||||||
|
};
|
||||||
|
for (const task of tasks) {
|
||||||
|
byColumn[task.column] = (byColumn[task.column] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { AgentStore } = await import("@fusion/core");
|
||||||
|
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||||
|
await agentStore.init();
|
||||||
|
const agents = await agentStore.listAgents();
|
||||||
|
const agentCounts = { idle: 0, active: 0, running: 0, error: 0 };
|
||||||
|
for (const agent of agents) {
|
||||||
|
const state = agent.state as keyof typeof agentCounts;
|
||||||
|
if (state in agentCounts) {
|
||||||
|
agentCounts[state] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
systemStats: {
|
||||||
|
rss: mem.rss,
|
||||||
|
heapUsed: mem.heapUsed,
|
||||||
|
heapTotal: mem.heapTotal,
|
||||||
|
heapLimit: heapStats.heap_size_limit,
|
||||||
|
external: mem.external,
|
||||||
|
arrayBuffers: mem.arrayBuffers,
|
||||||
|
cpuPercent: null,
|
||||||
|
loadAvg: [load[0] ?? 0, load[1] ?? 0, load[2] ?? 0],
|
||||||
|
cpuCount: os.cpus().length,
|
||||||
|
systemTotalMem: os.totalmem(),
|
||||||
|
systemFreeMem: os.freemem(),
|
||||||
|
pid: process.pid,
|
||||||
|
nodeVersion: process.version,
|
||||||
|
platform: `${process.platform}/${process.arch}`,
|
||||||
|
},
|
||||||
|
taskStats: {
|
||||||
|
total: tasks.length,
|
||||||
|
byColumn,
|
||||||
|
active: tasks.filter((task) => task.column === "in-progress" || task.column === "in-review").length,
|
||||||
|
agents: agentCounts,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
rethrowAsApiError(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Backup Routes ─────────────────────────────────────────────────
|
// ── Backup Routes ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user