FN-6657: embed system stats in Command Center
Move system telemetry out of the global header modal and into Command Center as a dedicated System area. - add a Command Center System tab with live CPU, memory, heap, process, task, agent, and vitest controls - remove the legacy System Stats modal entry points from desktop and mobile navigation - update modal manager, docs, and regression coverage for the new embedded System area - add a patch changeset for the published Fusion package Files changed: .../fn-6657-system-stats-into-command-center.md | 5 + docs/architecture.md | 2 +- docs/dashboard-guide.md | 4 +- packages/dashboard/app/App.tsx | 7 - packages/dashboard/app/components/AppModals.tsx | 12 - packages/dashboard/app/components/Header.tsx | 9 - packages/dashboard/app/components/MobileNavBar.tsx | 12 - .../dashboard/app/components/SystemStatsModal.css | 319 ------------- .../dashboard/app/components/SystemStatsModal.tsx | 523 --------------------- .../app/components/__tests__/AppModals.test.tsx | 51 -- .../app/components/__tests__/Header.test.tsx | 12 - .../app/components/__tests__/MobileNavBar.test.tsx | 13 - .../components/__tests__/SystemStatsModal.test.tsx | 329 ------------- ...-merge-toggle-blank.mobile-integration.test.tsx | 1 - .../components/command-center/CommandCenter.tsx | 5 + .../__tests__/CommandCenter.mobile-scroll.test.tsx | 46 ++ .../__tests__/CommandCenter.test.tsx | 57 ++- .../__tests__/SystemStatsArea.test.tsx | 229 +++++++++ .../command-center/areas/SystemStatsArea.css | 122 +++++ .../command-center/areas/SystemStatsArea.tsx | 440 +++++++++++++++++ .../app/hooks/__tests__/useModalManager.test.ts | 23 - packages/dashboard/app/hooks/useModalManager.ts | 12 - 22 files changed, 906 insertions(+), 1327 deletions(-) Fusion-Task-Id: FN-6657 Fusion-Task-Lineage: 7593ee86-671e-4c95-96f8-a8dc8861e7b7
This commit is contained in:
5
.changeset/fn-6657-system-stats-into-command-center.md
Normal file
5
.changeset/fn-6657-system-stats-into-command-center.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Move System Stats into the Command Center as a redesigned graph-rich System area with gauges, trend sparklines, task/agent bars, and relocated Vitest controls; remove the standalone System Stats modal plus its Header and mobile More affordances.
|
||||
@@ -854,7 +854,7 @@ Operator setup + troubleshooting guide: **[Remote Access runbook](./remote-acces
|
||||
|
||||
Key server capabilities:
|
||||
- REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings
|
||||
- System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry (including app CPU percentage and host memory rendered as numeric values with visual usage bars in the System Stats modal), task/agent aggregates, and manual vitest process termination
|
||||
- System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry (including app CPU percentage and host memory rendered as numeric values, radial gauges, and trend sparklines in the Command Center System area), task/agent aggregates, and manual vitest process termination
|
||||
- Remote access APIs (`/api/remote/*`) for provider config, activation, tunnel lifecycle, status, token issuance, authenticated URL generation, and QR payload generation
|
||||
- Operational runbook (prereqs/security/troubleshooting): [`docs/remote-access.md`](./remote-access.md)
|
||||
- `/api/remote/tunnel/start`, `/api/remote/tunnel/stop`, and `/api/remote/tunnel/kill-external` cover tunnel lifecycle and external funnel cleanup.
|
||||
|
||||
@@ -654,7 +654,7 @@ Features:
|
||||
|
||||
## Command Center
|
||||
|
||||
Command Center is the combined analytics and live-operations surface for a project: it pairs historical usage, cost, and throughput analytics with a live Mission Control panel.
|
||||
Command Center is the combined analytics and live-operations surface for a project: it pairs historical usage, cost, throughput analytics, live system telemetry, and a live Mission Control panel.
|
||||
|
||||
Navigation:
|
||||
- Desktop: **Header → More views → Command Center**
|
||||
@@ -672,6 +672,7 @@ Features:
|
||||
- **Ecosystem** shows active model breadth and per-model task activity; unavailable plugin-activation metrics render as unavailable rather than zero.
|
||||
- **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. The area shows filed/fixed/net stat cards, filed-vs-fixed daily sparklines, and a by-repository bar breakdown.
|
||||
- **Signals** shows external signal totals, open/resolved counts, MTTR, and source/severity breakdowns when signal sources are connected.
|
||||
- **System** is the canonical system-telemetry destination. It reuses `GET /api/system-stats` with no new endpoint, renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory trend sparklines, and charts tasks by column plus agents by state with the shared Command Center chart primitives. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed.
|
||||
- **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. Motion-heavy accents respect reduced-motion preferences.
|
||||
- CSV exports are available from the analytics endpoints with `?format=csv`. The Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`.
|
||||
|
||||
@@ -679,6 +680,7 @@ Data states:
|
||||
- Overview shows a loading state while core analytics settle, then shows `No usage data yet. Run some agents to populate the Command Center.` only after the selected range has settled with no core usage data.
|
||||
- GitHub issue analytics is local and additive: empty filed/fixed totals render the GitHub area's empty state; malformed historical `githubTracking` JSON is skipped instead of breaking the Command Center.
|
||||
- Team analytics renders its shared loading/error/empty states for null or zero-agent responses, omits empty chart shells for zero-value datasets, and keeps the Command Center tab panel as the mobile scroll owner.
|
||||
- System telemetry keeps the previous snapshot visible during refresh failures, renders a first-sample CPU `Sampling…` state without NaN values, shows zero-value task/agent bars for empty collections, and keeps the Command Center tab panel as the mobile scroll owner.
|
||||
- Signals is best-effort: if the Signals endpoint is absent or no signal source is connected, the Signals area falls back to its empty state and other Command Center metrics remain valid.
|
||||
|
||||
## Reliability View
|
||||
|
||||
@@ -1335,11 +1335,6 @@ function AppInner() {
|
||||
pushNav({ type: "modal", close: modalManager.closeGitManager });
|
||||
}, [modalManager, pushNav]);
|
||||
|
||||
const openSystemStatsWithNav = useCallback(() => {
|
||||
modalManager.openSystemStats();
|
||||
pushNav({ type: "modal", close: modalManager.closeSystemStats });
|
||||
}, [modalManager, pushNav]);
|
||||
|
||||
const openSchedulesWithNav = useCallback(() => {
|
||||
modalManager.openSchedules();
|
||||
pushNav({ type: "modal", close: modalManager.closeSchedules });
|
||||
@@ -1973,7 +1968,6 @@ function AppInner() {
|
||||
activePlanningSessionCount={bgPlanningSessions.length}
|
||||
onOpenUsage={openUsageWithNav}
|
||||
onOpenActivityLog={openActivityLogWithNav}
|
||||
onOpenSystemStats={openSystemStatsWithNav}
|
||||
onOpenMailbox={() => handleTaskViewChange("mailbox")}
|
||||
mailboxUnreadCount={mailboxUnreadCount}
|
||||
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
|
||||
@@ -2181,7 +2175,6 @@ function AppInner() {
|
||||
keyboardOpen={mobileNavKeyboardOpen}
|
||||
onOpenSettings={openSettingsWithNav}
|
||||
onOpenActivityLog={openActivityLogWithNav}
|
||||
onOpenSystemStats={openSystemStatsWithNav}
|
||||
onOpenMailbox={() => handleTaskViewChange("mailbox")}
|
||||
onOpenNodes={handleOpenNodesWithNav}
|
||||
mailboxUnreadCount={mailboxUnreadCount}
|
||||
|
||||
@@ -17,7 +17,6 @@ import { TodoModal } from "./TodoModal";
|
||||
import { UsageIndicator } from "./UsageIndicator";
|
||||
import { ScheduledTasksModal } from "./ScheduledTasksModal";
|
||||
import { NewTaskModal } from "./NewTaskModal";
|
||||
import { SystemStatsModal } from "./SystemStatsModal";
|
||||
import { ActivityLogModal } from "./ActivityLogModal";
|
||||
import { GitManagerModal } from "./GitManagerModal";
|
||||
import { AgentListModal } from "./AgentListModal";
|
||||
@@ -187,11 +186,6 @@ export function AppModals({
|
||||
modalManager.closeUsage();
|
||||
}, [modalManager.closeUsage, removeNav]);
|
||||
|
||||
const closeSystemStatsWithNav = useCallback(() => {
|
||||
removeNav(modalManager.closeSystemStats);
|
||||
modalManager.closeSystemStats();
|
||||
}, [modalManager.closeSystemStats, removeNav]);
|
||||
|
||||
const closeSchedulesWithNav = useCallback(() => {
|
||||
removeNav(modalManager.closeSchedules);
|
||||
modalManager.closeSchedules();
|
||||
@@ -426,12 +420,6 @@ export function AppModals({
|
||||
anchorRect={modalManager.usageAnchorRect}
|
||||
/>
|
||||
|
||||
<SystemStatsModal
|
||||
isOpen={modalManager.systemStatsOpen}
|
||||
onClose={closeSystemStatsWithNav}
|
||||
projectId={projectId}
|
||||
/>
|
||||
|
||||
{modalManager.schedulesOpen && (
|
||||
<ScheduledTasksModal
|
||||
onClose={closeSchedulesWithNav}
|
||||
|
||||
@@ -65,7 +65,6 @@ 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 */
|
||||
@@ -140,7 +139,6 @@ export function Header({
|
||||
activePlanningSessionCount = 0,
|
||||
onOpenUsage,
|
||||
onOpenActivityLog,
|
||||
onOpenSystemStats,
|
||||
onOpenMailbox,
|
||||
mailboxUnreadCount = 0,
|
||||
mailboxPendingApprovalCount = 0,
|
||||
@@ -1314,13 +1312,6 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* System Stats button - desktop only */}
|
||||
{!isCompact && onOpenSystemStats && (
|
||||
<button className="btn-icon" onClick={onOpenSystemStats} title={t("header.systemStats", "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={t("header.viewActivityLog", "View Activity Log")}>
|
||||
|
||||
@@ -75,7 +75,6 @@ export interface MobileNavBarProps {
|
||||
// Navigation handlers
|
||||
onOpenSettings?: () => void;
|
||||
onOpenActivityLog?: () => void;
|
||||
onOpenSystemStats?: () => void;
|
||||
onOpenMailbox?: () => void;
|
||||
mailboxUnreadCount?: number;
|
||||
mailboxPendingApprovalCount?: number;
|
||||
@@ -142,7 +141,6 @@ export function MobileNavBar({
|
||||
keyboardOpen = false,
|
||||
onOpenSettings,
|
||||
onOpenActivityLog,
|
||||
onOpenSystemStats,
|
||||
onOpenMailbox,
|
||||
mailboxUnreadCount = 0,
|
||||
mailboxPendingApprovalCount = 0,
|
||||
@@ -484,16 +482,6 @@ export function MobileNavBar({
|
||||
<span>{t("nav.activityLog", "Activity Log")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-more-item"
|
||||
data-testid="mobile-more-item-system-stats"
|
||||
onClick={() => handleMoreAction(onOpenSystemStats)}
|
||||
>
|
||||
<Monitor />
|
||||
<span>{t("nav.systemStats", "System Stats")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-more-item"
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
.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__auto-refresh {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.system-stats-modal__auto-refresh-time {
|
||||
color: var(--text-muted);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.system-stats-modal__header-actions .btn-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.system-stats-modal__header-actions .btn-icon svg {
|
||||
display: block;
|
||||
width: var(--btn-icon-size);
|
||||
height: var(--btn-icon-size);
|
||||
}
|
||||
|
||||
.system-stats-modal__refresh--spinning {
|
||||
animation: system-stats-modal-refresh-spin var(--duration-slow) linear infinite;
|
||||
}
|
||||
|
||||
@keyframes system-stats-modal-refresh-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.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--memory-used,
|
||||
.system-stats-modal__row--cpu-used {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.system-stats-modal__row--memory-used dd,
|
||||
.system-stats-modal__row--cpu-used dd {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.system-stats-modal__memory-progress-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.system-stats-modal__memory-progress-track {
|
||||
width: 100%;
|
||||
height: var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.system-stats-modal__memory-progress-track--warning {
|
||||
box-shadow: inset 0 0 0 var(--btn-border-width) var(--color-warning);
|
||||
}
|
||||
|
||||
.system-stats-modal__memory-progress-track--critical {
|
||||
box-shadow: inset 0 0 0 var(--btn-border-width) var(--color-error);
|
||||
}
|
||||
|
||||
.system-stats-modal__memory-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: width var(--transition-normal), background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.system-stats-modal__memory-progress-fill--normal {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.system-stats-modal__memory-progress-fill--warning {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.system-stats-modal__memory-progress-fill--critical {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.system-stats-modal__row dt {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.system-stats-modal__section-title--with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.system-stats-modal__vitest-controls {
|
||||
display: grid;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.system-stats-modal__kill-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.system-stats-modal__toggle-row,
|
||||
.system-stats-modal__threshold-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.system-stats-modal__threshold-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.system-stats-modal__threshold-controls input[type="range"] {
|
||||
accent-color: var(--todo);
|
||||
}
|
||||
|
||||
.system-stats-modal__toggle-row input[type="checkbox"] {
|
||||
accent-color: var(--todo);
|
||||
}
|
||||
|
||||
.system-stats-modal__threshold-row .input {
|
||||
width: 6ch;
|
||||
min-width: 6ch;
|
||||
}
|
||||
|
||||
.system-stats-modal__last-kill {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.system-stats-modal__kill-result {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.system-stats-modal__kill-result--success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.system-stats-modal__kill-result--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
.system-stats-modal__auto-refresh {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.system-stats-modal__header-actions .btn-icon {
|
||||
width: calc(var(--space-md) * 3);
|
||||
height: calc(var(--space-md) * 3);
|
||||
min-width: calc(var(--space-md) * 3);
|
||||
min-height: calc(var(--space-md) * 3);
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.system-stats-modal__toggle-row,
|
||||
.system-stats-modal__threshold-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.system-stats-modal__threshold-controls {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.system-stats-modal__threshold-controls input[type="range"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.system-stats-modal__threshold-row .input {
|
||||
width: 7ch;
|
||||
min-width: 7ch;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Monitor, RefreshCw, ShieldAlert, Skull, X } from "lucide-react";
|
||||
import {
|
||||
fetchGlobalSettings,
|
||||
fetchSystemStats,
|
||||
killVitestProcesses,
|
||||
updateGlobalSettings,
|
||||
type KillVitestResponse,
|
||||
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 cpuSeverity(percent: number | null, cores: number): Severity {
|
||||
if (percent === null || !Number.isFinite(percent) || percent < 0) return "normal";
|
||||
const normalized = cores > 0 ? percent / cores : percent;
|
||||
if (normalized >= 80) return "critical";
|
||||
if (normalized >= 50) return "warning";
|
||||
return "normal";
|
||||
}
|
||||
|
||||
function severityClassName(severity: Severity): string {
|
||||
if (severity === "critical") return "system-stats-modal__value--critical";
|
||||
if (severity === "warning") return "system-stats-modal__value--warning";
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null | undefined, notYetLabel: string): string {
|
||||
if (!value) return notYetLabel;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return notYetLabel;
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { t } = useTranslation("app");
|
||||
const [stats, setStats] = useState<SystemStatsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [autoKillEnabled, setAutoKillEnabled] = useState(true);
|
||||
const [killThreshold, setKillThreshold] = useState(90);
|
||||
const [isKilling, setIsKilling] = useState(false);
|
||||
const [confirmKill, setConfirmKill] = useState(false);
|
||||
const [killResult, setKillResult] = useState<KillVitestResponse | null>(null);
|
||||
const [settingsError, setSettingsError] = useState<string | null>(null);
|
||||
const [lastRefreshedAt, setLastRefreshedAt] = useState<number | null>(null);
|
||||
|
||||
const loadStats = useCallback(async (options?: { preserveKillResult?: boolean }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchSystemStats(projectId);
|
||||
setStats(response);
|
||||
setError(null);
|
||||
setLastRefreshedAt(Date.now());
|
||||
if (!options?.preserveKillResult) {
|
||||
setKillResult(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("systemStats.errorLoadStats", "Failed to load system stats"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
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 loadSettings = async () => {
|
||||
try {
|
||||
const settings = await fetchGlobalSettings();
|
||||
setAutoKillEnabled(settings.vitestAutoKillEnabled ?? true);
|
||||
setKillThreshold(settings.vitestKillThresholdPct ?? 90);
|
||||
setSettingsError(null);
|
||||
} catch (err) {
|
||||
setSettingsError(err instanceof Error ? err.message : t("systemStats.errorLoadVitestSettings", "Failed to load vitest settings"));
|
||||
}
|
||||
};
|
||||
|
||||
void loadSettings();
|
||||
}, [isOpen]);
|
||||
|
||||
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: t("systemStats.rowRss", "RSS"), value: formatBytes(system.rss), detail: toPercent(system.rss, system.systemTotalMem), className: rssClassName },
|
||||
{ label: t("systemStats.rowHeapUsed", "Heap Used"), value: formatBytes(system.heapUsed), detail: t("systemStats.rowHeapUsedDetail", "of {{total}}", { total: formatBytes(system.heapTotal) }), className: heapClassName },
|
||||
{ label: t("systemStats.rowHeapLimit", "Heap Limit"), value: formatBytes(system.heapLimit), detail: t("systemStats.rowHeapLimitDetail", "V8 limit") },
|
||||
{ label: t("systemStats.rowExternal", "External"), value: formatBytes(system.external) },
|
||||
{ label: t("systemStats.rowArrayBuffers", "Array Buffers"), value: formatBytes(system.arrayBuffers) },
|
||||
];
|
||||
}, [stats]);
|
||||
|
||||
const persistAutoKill = useCallback(async (enabled: boolean) => {
|
||||
setAutoKillEnabled(enabled);
|
||||
try {
|
||||
await updateGlobalSettings({ vitestAutoKillEnabled: enabled });
|
||||
setSettingsError(null);
|
||||
} catch (err) {
|
||||
setSettingsError(err instanceof Error ? err.message : t("systemStats.errorSaveVitestSettings", "Failed to save vitest settings"));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistKillThreshold = useCallback(async (nextThreshold: number) => {
|
||||
const clamped = Math.min(99, Math.max(50, Number.isFinite(nextThreshold) ? Math.round(nextThreshold) : 90));
|
||||
setKillThreshold(clamped);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ vitestKillThresholdPct: clamped });
|
||||
setSettingsError(null);
|
||||
} catch (err) {
|
||||
setSettingsError(err instanceof Error ? err.message : t("systemStats.errorSaveVitestSettings", "Failed to save vitest settings"));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKillVitest = useCallback(async () => {
|
||||
if (isKilling) return;
|
||||
if (!confirmKill) {
|
||||
setConfirmKill(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsKilling(true);
|
||||
try {
|
||||
const result = await killVitestProcesses(projectId);
|
||||
setKillResult(result);
|
||||
setConfirmKill(false);
|
||||
await loadStats({ preserveKillResult: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("systemStats.errorKillVitest", "Failed to kill vitest processes"));
|
||||
} finally {
|
||||
setIsKilling(false);
|
||||
}
|
||||
}, [confirmKill, isKilling, loadStats, projectId]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const system = stats?.systemStats;
|
||||
const isBackgroundRefreshing = loading && Boolean(stats);
|
||||
const refreshLabel = lastRefreshedAt
|
||||
? t("systemStats.updatedAt", "Updated {{time}}", {
|
||||
time: new Date(lastRefreshedAt).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}),
|
||||
})
|
||||
: t("systemStats.waitingFirstUpdate", "Waiting for first update");
|
||||
const taskStats = stats?.taskStats;
|
||||
const usedSystemMem = system ? system.systemTotalMem - system.systemFreeMem : 0;
|
||||
const usedSystemMemSeverity = system ? systemMemSeverity(usedSystemMem, system.systemTotalMem) : "normal";
|
||||
const usedSystemClassName = system ? severityClassName(usedSystemMemSeverity) : "";
|
||||
const usedSystemMemPercent =
|
||||
system && Number.isFinite(system.systemTotalMem) && system.systemTotalMem > 0
|
||||
? Math.max(0, Math.min(100, (usedSystemMem / system.systemTotalMem) * 100))
|
||||
: 0;
|
||||
const usedSystemMemProgressLabel = system
|
||||
? t("systemStats.systemMemProgressLabel", "System memory used: {{percent}}% ({{used}} of {{total}})", {
|
||||
percent: usedSystemMemPercent.toFixed(1),
|
||||
used: formatBytes(usedSystemMem),
|
||||
total: formatBytes(system.systemTotalMem),
|
||||
})
|
||||
: t("systemStats.systemMemUnavailable", "System memory usage unavailable");
|
||||
const vitestProcessCount = stats?.vitestProcessCount;
|
||||
const cpuLoadSeverity = cpuSeverity(system?.cpuPercent ?? null, system?.cpuCount ?? 0);
|
||||
const cpuClassName = severityClassName(cpuLoadSeverity);
|
||||
const cpuPercentValue = system?.cpuPercent ?? null;
|
||||
const cpuBarPercent = cpuPercentValue === null ? 0 : Math.max(0, Math.min(100, cpuPercentValue));
|
||||
const cpuPercentLabel = cpuPercentValue === null ? t("systemStats.cpuSampling", "Sampling…") : `${cpuPercentValue.toFixed(1)}%`;
|
||||
const cpuProgressLabel =
|
||||
cpuPercentValue === null
|
||||
? t("systemStats.cpuProgressUnavailable", "App CPU usage unavailable: waiting for another sample")
|
||||
: t("systemStats.cpuProgressLabel", "App CPU usage: {{percent}}%", { percent: cpuPercentValue.toFixed(1) });
|
||||
const killResultClassName = killResult
|
||||
? killResult.killed > 0
|
||||
? "system-stats-modal__kill-result system-stats-modal__kill-result--success"
|
||||
: "system-stats-modal__kill-result system-stats-modal__kill-result--error"
|
||||
: "";
|
||||
const lastAutoKillLabel = formatTimestamp(stats?.vitestLastAutoKillAt, t("systemStats.notYet", "Not yet"));
|
||||
|
||||
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>{t("systemStats.title", "System Stats")}</span>
|
||||
</h2>
|
||||
<div className="system-stats-modal__header-actions">
|
||||
<span className="system-stats-modal__auto-refresh" aria-live="polite">
|
||||
<span>{t("systemStats.autoRefresh", "Auto-refresh · 5s")}</span>
|
||||
<span className="system-stats-modal__auto-refresh-time">{refreshLabel}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => void loadStats()}
|
||||
title={t("systemStats.refreshTitle", "Refresh")}
|
||||
aria-label={t("systemStats.refreshAriaLabel", "Refresh system stats")}
|
||||
>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
className={isBackgroundRefreshing ? "system-stats-modal__refresh--spinning" : undefined}
|
||||
/>
|
||||
</button>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label={t("systemStats.closeAriaLabel", "Close")}>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && !stats && <div className="system-stats-modal__state">{t("systemStats.loading", "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={t("systemStats.sectionProcessAriaLabel", "Process stats")}>
|
||||
<h3 className="system-stats-modal__section-title">{t("systemStats.sectionProcess", "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={t("systemStats.sectionCpuAriaLabel", "CPU and load stats")}>
|
||||
<h3 className="system-stats-modal__section-title">{t("systemStats.sectionCpu", "CPU & Load")}</h3>
|
||||
<dl className="system-stats-modal__grid">
|
||||
<div className="system-stats-modal__row system-stats-modal__row--cpu-used">
|
||||
<dt>{t("systemStats.rowAppCpu", "App CPU")}</dt>
|
||||
<dd>
|
||||
<span className={`system-stats-modal__value ${cpuClassName}`.trim()}>{cpuPercentLabel}</span>
|
||||
<span className="system-stats-modal__detail">{cpuPercentValue === null ? t("systemStats.cpuFirstSamplePending", "First sample pending") : t("systemStats.cpuProcessUsage", "process usage")}</span>
|
||||
</dd>
|
||||
<div className="system-stats-modal__memory-progress-wrapper">
|
||||
<div
|
||||
className={`system-stats-modal__memory-progress-track system-stats-modal__memory-progress-track--${cpuLoadSeverity}`}
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(cpuBarPercent)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={cpuProgressLabel}
|
||||
>
|
||||
<div
|
||||
className={`system-stats-modal__memory-progress-fill system-stats-modal__memory-progress-fill--${cpuLoadSeverity}`}
|
||||
style={{ width: `${cpuBarPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="system-stats-modal__row">
|
||||
<dt>{t("systemStats.rowLoadAvg", "Load Avg")}</dt>
|
||||
<dd>{system?.loadAvg.map((value) => value.toFixed(2)).join(" ") ?? "—"}</dd>
|
||||
</div>
|
||||
<div className="system-stats-modal__row">
|
||||
<dt>{t("systemStats.rowCores", "Cores")}</dt>
|
||||
<dd>{system?.cpuCount ?? "—"}</dd>
|
||||
</div>
|
||||
<div className="system-stats-modal__row">
|
||||
<dt>{t("systemStats.rowPlatform", "Platform")}</dt>
|
||||
<dd>{system?.platform ?? "—"}</dd>
|
||||
</div>
|
||||
<div className="system-stats-modal__row">
|
||||
<dt>{t("systemStats.rowNode", "Node")}</dt>
|
||||
<dd>{system?.nodeVersion ?? "—"}</dd>
|
||||
</div>
|
||||
<div className="system-stats-modal__row">
|
||||
<dt>{t("systemStats.rowPid", "PID")}</dt>
|
||||
<dd>{system?.pid ?? "—"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="system-stats-modal__section" aria-label={t("systemStats.sectionSystemMemAriaLabel", "System memory stats")}>
|
||||
<h3 className="system-stats-modal__section-title">{t("systemStats.sectionSystem", "System")}</h3>
|
||||
<dl className="system-stats-modal__grid">
|
||||
<div className="system-stats-modal__row system-stats-modal__row--memory-used">
|
||||
<dt>{t("systemStats.rowMemoryUsed", "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 className="system-stats-modal__memory-progress-wrapper">
|
||||
<div
|
||||
className={`system-stats-modal__memory-progress-track system-stats-modal__memory-progress-track--${usedSystemMemSeverity}`}
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(usedSystemMemPercent)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={usedSystemMemProgressLabel}
|
||||
>
|
||||
<div
|
||||
className={`system-stats-modal__memory-progress-fill system-stats-modal__memory-progress-fill--${usedSystemMemSeverity}`}
|
||||
style={{ width: `${usedSystemMemPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="system-stats-modal__row">
|
||||
<dt>{t("systemStats.rowMemoryFree", "Memory Free")}</dt>
|
||||
<dd>{system ? formatBytes(system.systemFreeMem) : "—"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="system-stats-modal__section" aria-label={t("systemStats.sectionTasksAriaLabel", "Task stats")}>
|
||||
<h3 className="system-stats-modal__section-title">{t("systemStats.sectionTasks", "Tasks")}</h3>
|
||||
<dl className="system-stats-modal__grid">
|
||||
<div className="system-stats-modal__row">
|
||||
<dt>{t("systemStats.rowTotal", "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={t("systemStats.sectionAgentsAriaLabel", "Agent stats")}>
|
||||
<h3 className="system-stats-modal__section-title">{t("systemStats.sectionAgents", "Agents")}</h3>
|
||||
<dl className="system-stats-modal__grid">
|
||||
<div className="system-stats-modal__row">
|
||||
<dt>{t("systemStats.agentIdle", "idle")}</dt>
|
||||
<dd>{taskStats?.agents.idle ?? 0}</dd>
|
||||
</div>
|
||||
<div className="system-stats-modal__row">
|
||||
<dt>{t("systemStats.agentActive", "active")}</dt>
|
||||
<dd>{taskStats?.agents.active ?? 0}</dd>
|
||||
</div>
|
||||
<div className="system-stats-modal__row">
|
||||
<dt>{t("systemStats.agentRunning", "running")}</dt>
|
||||
<dd>{taskStats?.agents.running ?? 0}</dd>
|
||||
</div>
|
||||
<div className="system-stats-modal__row">
|
||||
<dt>{t("systemStats.agentError", "error")}</dt>
|
||||
<dd>{taskStats?.agents.error ?? 0}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="system-stats-modal__section" aria-label={t("systemStats.sectionVitestAriaLabel", "Vitest controls")}>
|
||||
<h3 className="system-stats-modal__section-title system-stats-modal__section-title--with-icon">
|
||||
<ShieldAlert />
|
||||
<span>{t("systemStats.sectionVitest", "Vitest Controls")}</span>
|
||||
</h3>
|
||||
<dl className="system-stats-modal__grid system-stats-modal__vitest-controls">
|
||||
<div className="system-stats-modal__row">
|
||||
<dt>{t("systemStats.vitestProcesses", "Vitest Processes")}</dt>
|
||||
<dd>{vitestProcessCount ?? "—"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="system-stats-modal__vitest-controls">
|
||||
<div className="system-stats-modal__kill-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={() => void handleKillVitest()}
|
||||
disabled={isKilling || vitestProcessCount === 0}
|
||||
>
|
||||
<Skull />
|
||||
<span>{confirmKill ? t("systemStats.confirmKill", "Confirm Kill?") : t("systemStats.killVitest", "Kill Vitest Processes")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="system-stats-modal__toggle-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoKillEnabled}
|
||||
onChange={(event) => {
|
||||
void persistAutoKill(event.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span>{t("systemStats.autoKillLabel", "Auto-kill vitest on memory pressure")}</span>
|
||||
</label>
|
||||
|
||||
<div className="system-stats-modal__threshold-row">
|
||||
<label htmlFor="vitest-threshold-number">{t("systemStats.killThresholdLabel", "Kill threshold (%)")}</label>
|
||||
<div className="system-stats-modal__threshold-controls">
|
||||
<input
|
||||
id="vitest-threshold-range"
|
||||
type="range"
|
||||
min={50}
|
||||
max={99}
|
||||
value={killThreshold}
|
||||
aria-label={t("systemStats.killThresholdSliderAriaLabel", "Kill threshold slider (%)")}
|
||||
onChange={(event) => {
|
||||
const nextValue = Number.parseInt(event.target.value, 10);
|
||||
void persistKillThreshold(Number.isNaN(nextValue) ? 90 : nextValue);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
id="vitest-threshold-number"
|
||||
type="number"
|
||||
className="input"
|
||||
min={50}
|
||||
max={99}
|
||||
value={killThreshold}
|
||||
aria-label={t("systemStats.killThresholdInputAriaLabel", "Kill threshold (%)")}
|
||||
onChange={(event) => {
|
||||
const nextValue = Number.parseInt(event.target.value, 10);
|
||||
void persistKillThreshold(Number.isNaN(nextValue) ? 90 : nextValue);
|
||||
}}
|
||||
onBlur={() => {
|
||||
void persistKillThreshold(killThreshold);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{killResult && <p className={killResultClassName}>{t("systemStats.killedProcesses", "Killed {{count}} processes", { count: killResult.killed })}</p>}
|
||||
<p className="system-stats-modal__last-kill">{t("systemStats.lastAutoKill", "Last auto-kill: {{time}}", { time: lastAutoKillLabel })}</p>
|
||||
{settingsError && <p className="system-stats-modal__kill-result system-stats-modal__kill-result--error">{settingsError}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && stats && <div className="system-stats-modal__footer-error">{t("systemStats.footerRefreshFailed", "Latest refresh failed: {{error}}", { error })}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,14 +76,6 @@ vi.mock("../NewTaskModal", () => ({
|
||||
NewTaskModal: () => null,
|
||||
}));
|
||||
|
||||
const mockSystemStatsModalProps = vi.fn();
|
||||
vi.mock("../SystemStatsModal", () => ({
|
||||
SystemStatsModal: (props: any) => {
|
||||
mockSystemStatsModalProps(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const mockActivityLogModalProps = vi.fn();
|
||||
vi.mock("../ActivityLogModal", () => ({
|
||||
ActivityLogModal: (props: any) => {
|
||||
@@ -182,7 +174,6 @@ describe("AppModals", () => {
|
||||
fileBrowserInitialFile: null,
|
||||
usageOpen: false,
|
||||
usageAnchorRect: null,
|
||||
systemStatsOpen: false,
|
||||
schedulesOpen: false,
|
||||
newTaskModalOpen: false,
|
||||
activityLogOpen: false,
|
||||
@@ -220,8 +211,6 @@ describe("AppModals", () => {
|
||||
setFileWorkspace: vi.fn(),
|
||||
openUsage: vi.fn(),
|
||||
closeUsage: vi.fn(),
|
||||
openSystemStats: vi.fn(),
|
||||
closeSystemStats: vi.fn(),
|
||||
openSchedules: vi.fn(),
|
||||
closeSchedules: vi.fn(),
|
||||
openNewTask: vi.fn(),
|
||||
@@ -257,7 +246,6 @@ describe("AppModals", () => {
|
||||
mockModelOnboardingModalProps.mockClear();
|
||||
mockActivityLogModalProps.mockClear();
|
||||
mockSettingsModalProps.mockClear();
|
||||
mockSystemStatsModalProps.mockClear();
|
||||
mockTodoModalProps.mockClear();
|
||||
});
|
||||
|
||||
@@ -530,45 +518,6 @@ describe("AppModals", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SystemStatsModal wiring", () => {
|
||||
const commonProps = {
|
||||
tasks: [],
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
toasts: mockToasts,
|
||||
removeToast: vi.fn(),
|
||||
projectActions: { handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() },
|
||||
taskHandlers: { handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() },
|
||||
taskOperations: { moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() },
|
||||
deepLink: { handleDetailClose: vi.fn() },
|
||||
settings: mockSettings,
|
||||
};
|
||||
|
||||
it("passes modal manager state and projectId through to SystemStatsModal", () => {
|
||||
const closeSystemStats = vi.fn();
|
||||
render(
|
||||
<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: expect.any(Function),
|
||||
projectId: "proj-system",
|
||||
}),
|
||||
);
|
||||
|
||||
mockSystemStatsModalProps.mock.calls[0][0].onClose();
|
||||
expect(closeSystemStats).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("task detail history wiring", () => {
|
||||
const commonProps = {
|
||||
projectId: "proj-1",
|
||||
|
||||
@@ -113,18 +113,6 @@ describe("Header", () => {
|
||||
expect(screen.getByTitle("Import from GitHub")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders system stats button on desktop when handler is provided", () => {
|
||||
renderHeader({ onOpenSystemStats: vi.fn() }, "desktop");
|
||||
expect(screen.getByTitle("System Stats")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onOpenSystemStats when system stats button is clicked", () => {
|
||||
const onOpenSystemStats = vi.fn();
|
||||
renderHeader({ onOpenSystemStats }, "desktop");
|
||||
fireEvent.click(screen.getByTitle("System Stats"));
|
||||
expect(onOpenSystemStats).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onOpenSettings when settings button is clicked", () => {
|
||||
const onOpenSettings = vi.fn();
|
||||
renderHeader({ onOpenSettings });
|
||||
|
||||
@@ -33,7 +33,6 @@ const createDefaultProps = () => ({
|
||||
modalOpen: false,
|
||||
onOpenSettings: vi.fn(),
|
||||
onOpenActivityLog: vi.fn(),
|
||||
onOpenSystemStats: vi.fn(),
|
||||
onOpenMailbox: vi.fn(),
|
||||
onOpenNodes: vi.fn(),
|
||||
mailboxUnreadCount: 0,
|
||||
@@ -393,7 +392,6 @@ describe("MobileNavBar", () => {
|
||||
|
||||
expect(screen.getByTestId("mobile-more-item-mailbox")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-more-item-activity")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-more-item-system-stats")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-more-item-git")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-more-item-terminal")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-more-item-files")).toBeDefined();
|
||||
@@ -563,17 +561,6 @@ describe("MobileNavBar", () => {
|
||||
expect(props.onOpenActivityLog).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("system stats item in more sheet calls onOpenSystemStats", () => {
|
||||
const props = createDefaultProps();
|
||||
const { container } = render(<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} />);
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { SystemStatsModal } from "../SystemStatsModal";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
Monitor: (props: { className?: string }) => <span data-testid="icon-monitor" {...props} />,
|
||||
RefreshCw: (props: { className?: string }) => <span data-testid="icon-refresh" {...props} />,
|
||||
ShieldAlert: (props: { className?: string }) => <span data-testid="icon-shield-alert" {...props} />,
|
||||
Skull: (props: { className?: string }) => <span data-testid="icon-skull" {...props} />,
|
||||
X: (props: { className?: string }) => <span data-testid="icon-x" {...props} />,
|
||||
}));
|
||||
|
||||
const mockFetchSystemStats = vi.fn();
|
||||
const mockFetchGlobalSettings = vi.fn();
|
||||
const mockKillVitestProcesses = vi.fn();
|
||||
const mockUpdateGlobalSettings = vi.fn();
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchSystemStats: (...args: unknown[]) => mockFetchSystemStats(...args),
|
||||
fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args),
|
||||
killVitestProcesses: (...args: unknown[]) => mockKillVitestProcesses(...args),
|
||||
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
|
||||
}));
|
||||
|
||||
const sampleStats = {
|
||||
systemStats: {
|
||||
rss: 5 * 1024 * 1024 * 1024,
|
||||
heapUsed: 900 * 1024 * 1024,
|
||||
heapTotal: 1200 * 1024 * 1024,
|
||||
heapLimit: 1000 * 1024 * 1024,
|
||||
external: 50 * 1024 * 1024,
|
||||
arrayBuffers: 20 * 1024 * 1024,
|
||||
cpuPercent: 68.4,
|
||||
loadAvg: [1.2, 0.8, 0.5] as [number, number, number],
|
||||
cpuCount: 8,
|
||||
systemTotalMem: 10 * 1024 * 1024 * 1024,
|
||||
systemFreeMem: 1024 * 1024 * 1024,
|
||||
pid: 12345,
|
||||
nodeVersion: "v22.0.0",
|
||||
platform: "darwin/arm64",
|
||||
},
|
||||
taskStats: {
|
||||
total: 6,
|
||||
byColumn: {
|
||||
triage: 1,
|
||||
todo: 2,
|
||||
"in-progress": 1,
|
||||
"in-review": 1,
|
||||
done: 1,
|
||||
archived: 0,
|
||||
},
|
||||
active: 2,
|
||||
agents: {
|
||||
idle: 1,
|
||||
active: 2,
|
||||
running: 0,
|
||||
error: 1,
|
||||
},
|
||||
},
|
||||
vitestProcessCount: 2,
|
||||
vitestLastAutoKillAt: "2026-04-27T12:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("SystemStatsModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchSystemStats.mockResolvedValue(sampleStats);
|
||||
mockFetchGlobalSettings.mockResolvedValue({
|
||||
vitestAutoKillEnabled: true,
|
||||
vitestKillThresholdPct: 90,
|
||||
});
|
||||
mockKillVitestProcesses.mockResolvedValue({ killed: 2, pids: [111, 222] });
|
||||
mockUpdateGlobalSettings.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows loading state while initial stats are fetched", async () => {
|
||||
mockFetchSystemStats.mockReturnValue(new Promise(() => undefined));
|
||||
|
||||
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Loading system stats…")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders fetched metrics across all sections", async () => {
|
||||
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} projectId="proj-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSystemStats).toHaveBeenCalledWith("proj-1");
|
||||
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(screen.getByText("System Stats")).toBeDefined();
|
||||
expect(await screen.findByText("Process")).toBeDefined();
|
||||
expect(screen.getByText("CPU & Load")).toBeDefined();
|
||||
expect(screen.getByText("System")).toBeDefined();
|
||||
expect(screen.getByText("Tasks")).toBeDefined();
|
||||
expect(screen.getByText("Agents")).toBeDefined();
|
||||
expect(screen.getByText("Vitest Controls")).toBeDefined();
|
||||
|
||||
expect(screen.getByText("5.00 GB")).toBeDefined();
|
||||
expect(screen.getByText("900 MB")).toBeDefined();
|
||||
expect(screen.getByText("9.00 GB")).toBeDefined();
|
||||
expect(screen.getByText("90.0% of 10.00 GB")).toBeDefined();
|
||||
|
||||
const memoryUsageProgress = screen.getByRole("progressbar", {
|
||||
name: "System memory used: 90.0% (9.00 GB of 10.00 GB)",
|
||||
});
|
||||
expect(memoryUsageProgress).toHaveAttribute("aria-valuenow", "90");
|
||||
expect(memoryUsageProgress).toHaveAttribute("aria-valuemin", "0");
|
||||
expect(memoryUsageProgress).toHaveAttribute("aria-valuemax", "100");
|
||||
expect(memoryUsageProgress.className).toContain("system-stats-modal__memory-progress-track--critical");
|
||||
const criticalFill = memoryUsageProgress.querySelector(".system-stats-modal__memory-progress-fill");
|
||||
expect(criticalFill?.className).toContain("system-stats-modal__memory-progress-fill--critical");
|
||||
|
||||
expect(screen.getByText("68.4%")).toBeDefined();
|
||||
const cpuUsageProgress = screen.getByRole("progressbar", {
|
||||
name: "App CPU usage: 68.4%",
|
||||
});
|
||||
expect(cpuUsageProgress).toHaveAttribute("aria-valuenow", "68");
|
||||
expect(cpuUsageProgress.className).toContain("system-stats-modal__memory-progress-track--normal");
|
||||
|
||||
expect(screen.getByText("1.20 0.80 0.50")).toBeDefined();
|
||||
expect(screen.getByText("Vitest Processes")).toBeDefined();
|
||||
expect(screen.getByText(/Last auto-kill:/)).toBeDefined();
|
||||
|
||||
const criticalValues = document.querySelectorAll(".system-stats-modal__value--critical");
|
||||
expect(criticalValues.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps memory value and bar severity aligned for warning thresholds", async () => {
|
||||
mockFetchSystemStats.mockResolvedValue({
|
||||
...sampleStats,
|
||||
systemStats: {
|
||||
...sampleStats.systemStats,
|
||||
systemFreeMem: 2 * 1024 * 1024 * 1024,
|
||||
},
|
||||
});
|
||||
|
||||
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
const memoryUsageProgress = await screen.findByRole("progressbar", {
|
||||
name: "System memory used: 80.0% (8.00 GB of 10.00 GB)",
|
||||
});
|
||||
expect(memoryUsageProgress.className).toContain("system-stats-modal__memory-progress-track--warning");
|
||||
const warningFill = memoryUsageProgress.querySelector(".system-stats-modal__memory-progress-fill");
|
||||
expect(warningFill?.className).toContain("system-stats-modal__memory-progress-fill--warning");
|
||||
|
||||
const memoryValue = screen.getByText("8.00 GB");
|
||||
expect(memoryValue.className).toContain("system-stats-modal__value--warning");
|
||||
});
|
||||
|
||||
it("shows refresh button icon when the modal is open", async () => {
|
||||
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
const refreshButton = await screen.findByRole("button", { name: "Refresh system stats" });
|
||||
const refreshIcon = screen.getByTestId("icon-refresh");
|
||||
|
||||
expect(refreshButton).toBeDefined();
|
||||
expect(refreshIcon).toBeDefined();
|
||||
expect(refreshButton.contains(refreshIcon)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows auto-refresh indicator when stats are loaded", async () => {
|
||||
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Auto-refresh · 5s")).toBeDefined();
|
||||
expect(screen.getByText(/Updated|Waiting for first update/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("applies spinning class to refresh icon during background refresh", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
let callCount = 0;
|
||||
let resolveBackgroundRefresh: (() => void) | undefined;
|
||||
const backgroundRefreshPromise = new Promise<typeof sampleStats>((resolve) => {
|
||||
resolveBackgroundRefresh = () => resolve(sampleStats);
|
||||
});
|
||||
|
||||
mockFetchSystemStats.mockImplementation(() => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return Promise.resolve(sampleStats);
|
||||
}
|
||||
return backgroundRefreshPromise;
|
||||
});
|
||||
|
||||
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByText("Auto-refresh · 5s")).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("icon-refresh").className).toContain("system-stats-modal__refresh--spinning");
|
||||
|
||||
resolveBackgroundRefresh?.();
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state when initial fetch fails", async () => {
|
||||
mockFetchSystemStats.mockRejectedValue(new Error("stats unavailable"));
|
||||
|
||||
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("stats unavailable");
|
||||
});
|
||||
|
||||
it("requires a confirmation click before killing vitest processes", async () => {
|
||||
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} projectId="proj-1" />);
|
||||
|
||||
const killButton = await screen.findByRole("button", { name: /Kill Vitest Processes/i });
|
||||
|
||||
fireEvent.click(killButton);
|
||||
expect(mockKillVitestProcesses).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: /Confirm Kill\?/i })).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Confirm Kill\?/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKillVitestProcesses).toHaveBeenCalledWith("proj-1");
|
||||
expect(screen.getByText("Killed 2 processes")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("persists auto-kill toggle changes", async () => {
|
||||
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
const toggle = (await screen.findByLabelText("Auto-kill vitest on memory pressure")) as HTMLInputElement;
|
||||
expect(toggle.checked).toBe(true);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestAutoKillEnabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps threshold input to allowed range", async () => {
|
||||
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
const thresholdInput = (await screen.findByLabelText("Kill threshold (%)")) as HTMLInputElement;
|
||||
|
||||
fireEvent.change(thresholdInput, { target: { value: "20" } });
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestKillThresholdPct: 50 });
|
||||
});
|
||||
|
||||
fireEvent.change(thresholdInput, { target: { value: "120" } });
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestKillThresholdPct: 99 });
|
||||
});
|
||||
});
|
||||
|
||||
it("persists threshold changes from the slider control", async () => {
|
||||
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
const thresholdSlider = (await screen.findByLabelText("Kill threshold slider (%)")) as HTMLInputElement;
|
||||
fireEvent.change(thresholdSlider, { target: { value: "95" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestKillThresholdPct: 95 });
|
||||
});
|
||||
});
|
||||
|
||||
it("shows deterministic fallback copy when app CPU percentage is unavailable", async () => {
|
||||
mockFetchSystemStats.mockResolvedValue({
|
||||
...sampleStats,
|
||||
systemStats: {
|
||||
...sampleStats.systemStats,
|
||||
cpuPercent: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Sampling…")).toBeDefined();
|
||||
const cpuUsageProgress = screen.getByRole("progressbar", {
|
||||
name: "App CPU usage unavailable: waiting for another sample",
|
||||
});
|
||||
expect(cpuUsageProgress).toHaveAttribute("aria-valuenow", "0");
|
||||
});
|
||||
|
||||
it("shows fallback text when last auto-kill timestamp is unavailable", async () => {
|
||||
mockFetchSystemStats.mockResolvedValue({
|
||||
...sampleStats,
|
||||
vitestLastAutoKillAt: null,
|
||||
});
|
||||
|
||||
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Last auto-kill: Not yet")).toBeDefined();
|
||||
});
|
||||
|
||||
it("refreshes every 5 seconds while open and stops when closed", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { rerender } = render(<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);
|
||||
});
|
||||
});
|
||||
@@ -318,7 +318,6 @@ function AppShellMobileHarness({ tasks }: { tasks: Task[] }) {
|
||||
keyboardOpen={keyboardOpen}
|
||||
onOpenSettings={vi.fn()}
|
||||
onOpenActivityLog={vi.fn()}
|
||||
onOpenSystemStats={vi.fn()}
|
||||
onOpenMailbox={vi.fn()}
|
||||
onOpenGitManager={vi.fn()}
|
||||
onOpenWorkflowEditor={vi.fn()}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TeamArea } from "./areas/TeamArea";
|
||||
import { EcosystemArea } from "./areas/EcosystemArea";
|
||||
import { GithubArea } from "./areas/GithubArea";
|
||||
import { SignalsArea } from "./areas/SignalsArea";
|
||||
import { SystemStatsArea } from "./areas/SystemStatsArea";
|
||||
import { MissionControlPanel } from "./MissionControlPanel";
|
||||
import { SdlcFunnel } from "./SdlcFunnel";
|
||||
import { Bar, type BarDatum } from "./charts/Bar";
|
||||
@@ -31,6 +32,7 @@ type SubViewId =
|
||||
| "ecosystem"
|
||||
| "github"
|
||||
| "signals"
|
||||
| "system"
|
||||
| "mission-control";
|
||||
|
||||
interface SubView {
|
||||
@@ -54,6 +56,7 @@ function useSubViews(): SubView[] {
|
||||
{ id: "ecosystem", label: t("commandCenter.tabs.ecosystem", "Ecosystem") },
|
||||
{ id: "github", label: t("commandCenter.tabs.github", "GitHub") },
|
||||
{ id: "signals", label: t("commandCenter.tabs.signals", "Signals") },
|
||||
{ id: "system", label: t("commandCenter.tabs.system", "System") },
|
||||
{ id: "mission-control", label: t("commandCenter.tabs.missionControl", "Mission Control") },
|
||||
];
|
||||
}
|
||||
@@ -441,6 +444,8 @@ export function CommandCenter() {
|
||||
return <GithubArea range={range} />;
|
||||
case "signals":
|
||||
return <SignalsArea range={range} />;
|
||||
case "system":
|
||||
return <SystemStatsArea />;
|
||||
case "mission-control":
|
||||
return <MissionControlPanel />;
|
||||
default:
|
||||
|
||||
@@ -11,6 +11,13 @@ vi.mock("../../../api/legacy", () => ({
|
||||
api: (path: string, opts?: RequestInit) => apiMock(path, opts),
|
||||
}));
|
||||
|
||||
vi.mock("../../../api", () => ({
|
||||
fetchSystemStats: () => Promise.resolve(systemStatsFixture()),
|
||||
fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }),
|
||||
killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }),
|
||||
updateGlobalSettings: () => Promise.resolve({}),
|
||||
}));
|
||||
|
||||
function emptyTokenFixture() {
|
||||
return {
|
||||
totals: { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0 },
|
||||
@@ -128,6 +135,37 @@ function populatedActivityFixture() {
|
||||
};
|
||||
}
|
||||
|
||||
function systemStatsFixture() {
|
||||
const gb = 1024 * 1024 * 1024;
|
||||
const mb = 1024 * 1024;
|
||||
return {
|
||||
systemStats: {
|
||||
rss: 2 * gb,
|
||||
heapUsed: 500 * mb,
|
||||
heapTotal: 700 * mb,
|
||||
heapLimit: 1 * gb,
|
||||
external: 20 * mb,
|
||||
arrayBuffers: 8 * mb,
|
||||
cpuPercent: 12,
|
||||
loadAvg: [0.1, 0.2, 0.3] as [number, number, number],
|
||||
cpuCount: 8,
|
||||
systemTotalMem: 8 * gb,
|
||||
systemFreeMem: 4 * gb,
|
||||
pid: 456,
|
||||
nodeVersion: "v22.0.0",
|
||||
platform: "darwin/arm64",
|
||||
},
|
||||
taskStats: {
|
||||
total: 1,
|
||||
byColumn: { todo: 1 },
|
||||
active: 0,
|
||||
agents: { idle: 1, active: 0, running: 0, error: 0 },
|
||||
},
|
||||
vitestProcessCount: 0,
|
||||
vitestLastAutoKillAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mockOverviewApi({ populated = false }: { populated?: boolean } = {}) {
|
||||
apiMock.mockImplementation((path: string) => {
|
||||
if (path.startsWith("/command-center/tokens")) return Promise.resolve(populated ? populatedTokenFixture() : emptyTokenFixture());
|
||||
@@ -136,6 +174,8 @@ function mockOverviewApi({ populated = false }: { populated?: boolean } = {}) {
|
||||
if (path.startsWith("/command-center/github")) return Promise.resolve(emptyGithubFixture());
|
||||
if (path.startsWith("/command-center/team")) return Promise.resolve(emptyTeamFixture());
|
||||
if (path.startsWith("/command-center/signals")) return Promise.resolve({ totalSignals: 0, open: 0, resolved: 0, mttr: { value: null, unavailable: true }, bySource: [], bySeverity: [] });
|
||||
if (path === "/system-stats") return Promise.resolve(systemStatsFixture());
|
||||
if (path === "/settings/global") return Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 });
|
||||
if (path === "/command-center/live") {
|
||||
return Promise.resolve({
|
||||
capturedAt: "2026-06-18T00:00:00.000Z",
|
||||
@@ -224,6 +264,12 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => {
|
||||
const githubPanel = screen.getByTestId("command-center-panel-github");
|
||||
expect(githubPanel).toBe(screen.getByRole("tabpanel"));
|
||||
assertScrollOwnerContract(githubPanel);
|
||||
|
||||
fireEvent.click(screen.getByTestId("command-center-tab-system"));
|
||||
const systemPanel = screen.getByTestId("command-center-panel-system");
|
||||
expect(systemPanel).toBe(screen.getByRole("tabpanel"));
|
||||
await screen.findByTestId("cc-area-system");
|
||||
assertScrollOwnerContract(systemPanel);
|
||||
});
|
||||
|
||||
it("preserves the mobile scroll owner when the populated Overview charts render", async () => {
|
||||
|
||||
@@ -11,6 +11,13 @@ vi.mock("../../../api/legacy", () => ({
|
||||
api: (path: string, opts?: RequestInit) => apiMock(path, opts),
|
||||
}));
|
||||
|
||||
vi.mock("../../../api", () => ({
|
||||
fetchSystemStats: () => Promise.resolve(systemStatsFixture()),
|
||||
fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }),
|
||||
killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }),
|
||||
updateGlobalSettings: () => Promise.resolve({}),
|
||||
}));
|
||||
|
||||
function tokenFixture(totalTokens = 1_500) {
|
||||
return {
|
||||
from: "2026-06-08",
|
||||
@@ -182,6 +189,37 @@ function liveFixture(columns: Array<{ column: string; count: number }> = [{ colu
|
||||
};
|
||||
}
|
||||
|
||||
function systemStatsFixture() {
|
||||
const gb = 1024 * 1024 * 1024;
|
||||
const mb = 1024 * 1024;
|
||||
return {
|
||||
systemStats: {
|
||||
rss: 2 * gb,
|
||||
heapUsed: 500 * mb,
|
||||
heapTotal: 700 * mb,
|
||||
heapLimit: 1 * gb,
|
||||
external: 20 * mb,
|
||||
arrayBuffers: 8 * mb,
|
||||
cpuPercent: 12,
|
||||
loadAvg: [0.1, 0.2, 0.3] as [number, number, number],
|
||||
cpuCount: 8,
|
||||
systemTotalMem: 8 * gb,
|
||||
systemFreeMem: 4 * gb,
|
||||
pid: 456,
|
||||
nodeVersion: "v22.0.0",
|
||||
platform: "darwin/arm64",
|
||||
},
|
||||
taskStats: {
|
||||
total: 1,
|
||||
byColumn: { todo: 1 },
|
||||
active: 0,
|
||||
agents: { idle: 1, active: 0, running: 0, error: 0 },
|
||||
},
|
||||
vitestProcessCount: 0,
|
||||
vitestLastAutoKillAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mockOverviewApi({
|
||||
tokens = tokenFixture(),
|
||||
tools = toolsFixture(),
|
||||
@@ -211,6 +249,8 @@ function mockOverviewApi({
|
||||
if (path === "/command-center/live") {
|
||||
return live instanceof Error ? Promise.reject(live) : Promise.resolve(live);
|
||||
}
|
||||
if (path === "/system-stats") return Promise.resolve(systemStatsFixture());
|
||||
if (path === "/settings/global") return Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 });
|
||||
return Promise.reject(new Error(`Unhandled api path: ${path}`));
|
||||
});
|
||||
}
|
||||
@@ -520,8 +560,8 @@ describe("CommandCenter shell", () => {
|
||||
render(<CommandCenter />);
|
||||
const tablist = screen.getByRole("tablist");
|
||||
const tabs = within(tablist).getAllByRole("tab");
|
||||
// Overview, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, Mission Control.
|
||||
expect(tabs.length).toBe(10);
|
||||
// Overview, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, System, Mission Control.
|
||||
expect(tabs.length).toBe(11);
|
||||
// roving tabindex: exactly one tab is focusable.
|
||||
const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0");
|
||||
expect(focusable.length).toBe(1);
|
||||
@@ -536,6 +576,18 @@ describe("CommandCenter shell", () => {
|
||||
expect(screen.getByTestId("command-center-panel-tokens")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders and routes the System tab exactly once", async () => {
|
||||
mockOverviewApi();
|
||||
render(<CommandCenter />);
|
||||
expect(screen.getAllByTestId("command-center-tab-system")).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByTestId("command-center-tab-system"));
|
||||
expect(screen.getByTestId("command-center-tab-system").getAttribute("aria-selected")).toBe("true");
|
||||
expect(screen.getByTestId("command-center-panel-system")).toBeTruthy();
|
||||
await screen.findByTestId("cc-area-system");
|
||||
expect(screen.getByTestId("cc-system-cpu-gauge")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders and routes the GitHub tab exactly once", async () => {
|
||||
mockOverviewApi({ github: githubFixture(4, 2) });
|
||||
render(<CommandCenter />);
|
||||
@@ -642,6 +694,7 @@ describe("CommandCenter shell", () => {
|
||||
"ecosystem",
|
||||
"github",
|
||||
"signals",
|
||||
"system",
|
||||
"mission-control",
|
||||
"team",
|
||||
]) {
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { SystemStatsArea } from "../areas/SystemStatsArea";
|
||||
|
||||
const mockFetchSystemStats = vi.fn();
|
||||
const mockFetchGlobalSettings = vi.fn();
|
||||
const mockKillVitestProcesses = vi.fn();
|
||||
const mockUpdateGlobalSettings = vi.fn();
|
||||
|
||||
vi.mock("../../../api", () => ({
|
||||
fetchSystemStats: (...args: unknown[]) => mockFetchSystemStats(...args),
|
||||
fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args),
|
||||
killVitestProcesses: (...args: unknown[]) => mockKillVitestProcesses(...args),
|
||||
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
|
||||
}));
|
||||
|
||||
const gb = 1024 * 1024 * 1024;
|
||||
const mb = 1024 * 1024;
|
||||
|
||||
type SystemStatsFixture = ReturnType<typeof baseStats>;
|
||||
type SystemStatsFixtureOverrides = Partial<Omit<SystemStatsFixture, "systemStats" | "taskStats">> & {
|
||||
systemStats?: Partial<SystemStatsFixture["systemStats"]>;
|
||||
taskStats?: Partial<Omit<SystemStatsFixture["taskStats"], "agents">> & {
|
||||
agents?: Partial<SystemStatsFixture["taskStats"]["agents"]>;
|
||||
};
|
||||
};
|
||||
|
||||
function sampleStats(overrides: SystemStatsFixtureOverrides = {}) {
|
||||
return {
|
||||
...baseStats(),
|
||||
...overrides,
|
||||
systemStats: {
|
||||
...baseStats().systemStats,
|
||||
...overrides.systemStats,
|
||||
},
|
||||
taskStats: {
|
||||
...baseStats().taskStats,
|
||||
...overrides.taskStats,
|
||||
agents: {
|
||||
...baseStats().taskStats.agents,
|
||||
...overrides.taskStats?.agents,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function baseStats() {
|
||||
return {
|
||||
systemStats: {
|
||||
rss: 5 * gb,
|
||||
heapUsed: 900 * mb,
|
||||
heapTotal: 1200 * mb,
|
||||
heapLimit: 1000 * mb,
|
||||
external: 50 * mb,
|
||||
arrayBuffers: 20 * mb,
|
||||
cpuPercent: 68.4,
|
||||
loadAvg: [1.2, 0.8, 0.5] as [number, number, number],
|
||||
cpuCount: 8,
|
||||
systemTotalMem: 10 * gb,
|
||||
systemFreeMem: 1 * gb,
|
||||
pid: 12345,
|
||||
nodeVersion: "v22.0.0",
|
||||
platform: "darwin/arm64",
|
||||
},
|
||||
taskStats: {
|
||||
total: 6,
|
||||
byColumn: {
|
||||
triage: 1,
|
||||
todo: 2,
|
||||
"in-progress": 1,
|
||||
"in-review": 1,
|
||||
done: 1,
|
||||
},
|
||||
active: 2,
|
||||
agents: {
|
||||
idle: 1,
|
||||
active: 2,
|
||||
running: 0,
|
||||
error: 1,
|
||||
},
|
||||
},
|
||||
vitestProcessCount: 2,
|
||||
vitestLastAutoKillAt: "2026-04-27T12:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("SystemStatsArea", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchSystemStats.mockResolvedValue(sampleStats());
|
||||
mockFetchGlobalSettings.mockResolvedValue({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 });
|
||||
mockKillVitestProcesses.mockResolvedValue({ killed: 2, pids: [111, 222] });
|
||||
mockUpdateGlobalSettings.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders gauges, trends, bars, details, and Vitest controls for populated stats", async () => {
|
||||
render(<SystemStatsArea projectId="proj-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSystemStats).toHaveBeenCalledWith("proj-1");
|
||||
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(await screen.findByTestId("cc-area-system")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cc-system-cpu-gauge")).toHaveTextContent("68%");
|
||||
expect(screen.getByTestId("cc-system-mem-gauge")).toHaveTextContent("90%");
|
||||
expect(screen.getByTestId("cc-system-heap-gauge")).toHaveTextContent("90%");
|
||||
expect(screen.getByTestId("cc-system-cpu-trend")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cc-system-memory-trend")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cc-system-tasks-bar")).toHaveTextContent("in-progress");
|
||||
expect(screen.getByTestId("cc-system-agents-bar")).toHaveTextContent("active");
|
||||
expect(screen.getByTestId("cc-system-details-grid")).toHaveTextContent("RSS");
|
||||
expect(screen.getByTestId("cc-system-details-grid")).toHaveTextContent("5.00 GB");
|
||||
expect(screen.getByTestId("cc-system-vitest-controls")).toHaveTextContent("Vitest Processes");
|
||||
});
|
||||
|
||||
it("renders the first-sample CPU state safely without NaN", async () => {
|
||||
mockFetchSystemStats.mockResolvedValue(sampleStats({ systemStats: { cpuPercent: null } }));
|
||||
|
||||
render(<SystemStatsArea />);
|
||||
|
||||
await screen.findByTestId("cc-area-system");
|
||||
expect(screen.getByTestId("cc-system-cpu-gauge")).toHaveTextContent("—");
|
||||
expect(screen.getByTestId("cc-system-cpu-gauge")).toHaveTextContent("Sampling");
|
||||
expect(screen.getByTestId("cc-area-system")).not.toHaveTextContent("NaN");
|
||||
});
|
||||
|
||||
it("renders zero-value task and agent bars when collections are empty", async () => {
|
||||
mockFetchSystemStats.mockResolvedValue(sampleStats({
|
||||
taskStats: {
|
||||
total: 0,
|
||||
byColumn: {},
|
||||
active: 0,
|
||||
agents: { idle: 0, active: 0, running: 0, error: 0 },
|
||||
},
|
||||
}));
|
||||
|
||||
render(<SystemStatsArea />);
|
||||
|
||||
await screen.findByTestId("cc-area-system");
|
||||
const taskBars = screen.getByTestId("cc-system-tasks-bar");
|
||||
const agentBars = screen.getByTestId("cc-system-agents-bar");
|
||||
expect(within(taskBars).getByText("triage")).toBeInTheDocument();
|
||||
expect(within(agentBars).getByText("idle")).toBeInTheDocument();
|
||||
expect(within(taskBars).getAllByText("0").length).toBeGreaterThan(0);
|
||||
expect(within(agentBars).getAllByText("0").length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId("cc-area-system")).not.toHaveTextContent("NaN");
|
||||
});
|
||||
|
||||
it("keeps the last stats visible when a later poll fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockFetchSystemStats.mockResolvedValueOnce(sampleStats()).mockRejectedValueOnce(new Error("poll failed"));
|
||||
|
||||
render(<SystemStatsArea />);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByTestId("cc-area-system")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("cc-area-system")).toHaveTextContent("Latest refresh failed: poll failed");
|
||||
expect(screen.getByTestId("cc-system-details-grid")).toHaveTextContent("RSS");
|
||||
});
|
||||
|
||||
it("shows the initial error state when the first fetch fails", async () => {
|
||||
mockFetchSystemStats.mockRejectedValue(new Error("initial failure"));
|
||||
|
||||
render(<SystemStatsArea />);
|
||||
|
||||
expect(await screen.findByTestId("cc-area-system-error")).toHaveTextContent("initial failure");
|
||||
});
|
||||
|
||||
it("confirms before killing Vitest and persists settings changes", async () => {
|
||||
render(<SystemStatsArea projectId="proj-1" />);
|
||||
await screen.findByTestId("cc-area-system");
|
||||
|
||||
const killButton = screen.getByTestId("cc-system-kill-vitest");
|
||||
fireEvent.click(killButton);
|
||||
expect(killButton).toHaveTextContent("Confirm Kill?");
|
||||
fireEvent.click(killButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKillVitestProcesses).toHaveBeenCalledWith("proj-1");
|
||||
});
|
||||
expect(await screen.findByText("Killed 2 processes")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Auto-kill vitest on memory pressure"));
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestAutoKillEnabled: false });
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Kill threshold (%)"), { target: { value: "120" } });
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestKillThresholdPct: 99 });
|
||||
});
|
||||
});
|
||||
|
||||
it("polls every five seconds and clears the interval on unmount", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { unmount } = render(<SystemStatsArea />);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByTestId("cc-area-system")).toBeInTheDocument();
|
||||
expect(mockFetchSystemStats).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mockFetchSystemStats).toHaveBeenCalledTimes(2);
|
||||
|
||||
unmount();
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mockFetchSystemStats).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
FNXC:CommandCenter 2026-06-18-00:00:
|
||||
The Command Center System area replaces the standalone System Stats modal with graph-heavy telemetry while preserving the Command Center mobile scroll contract; keep area-specific styling layout-only and avoid nested overflow containers so .cc-tabpanel remains the sole vertical scroller.
|
||||
*/
|
||||
|
||||
.cc-system-refresh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.cc-system-refresh .btn-icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cc-system-gauges .cc-stat-card {
|
||||
min-block-size: 100%;
|
||||
}
|
||||
|
||||
.cc-system-chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.cc-system-chart-grid .cc-stat-card {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.cc-system-section-title-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.cc-system-section-title-with-icon svg {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cc-system-vitest-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.cc-system-vitest-card .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.cc-system-toggle-row,
|
||||
.cc-system-threshold-row,
|
||||
.cc-system-threshold-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.cc-system-toggle-row,
|
||||
.cc-system-threshold-row {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.cc-system-threshold-controls input[type="range"] {
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.cc-system-threshold-controls .input {
|
||||
inline-size: 5rem;
|
||||
}
|
||||
|
||||
.cc-system-note {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.cc-system-note--error,
|
||||
.cc-system-value--critical {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.cc-system-value--warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.cc-system-note--success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cc-system-refresh {
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cc-system-chart-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cc-system-vitest-card,
|
||||
.cc-system-toggle-row,
|
||||
.cc-system-threshold-row,
|
||||
.cc-system-threshold-controls {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.cc-system-vitest-card .btn,
|
||||
.cc-system-threshold-controls .input {
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw, ShieldAlert, Skull } from "lucide-react";
|
||||
import {
|
||||
fetchGlobalSettings,
|
||||
fetchSystemStats,
|
||||
killVitestProcesses,
|
||||
updateGlobalSettings,
|
||||
type KillVitestResponse,
|
||||
type SystemStatsResponse,
|
||||
} from "../../../api";
|
||||
import { Bar, type BarDatum } from "../charts/Bar";
|
||||
import { RadialGauge } from "../charts/RadialGauge";
|
||||
import { Sparkline } from "../charts/Sparkline";
|
||||
import { AreaShell } from "./AreaShell";
|
||||
import { formatCount } from "./areaShared";
|
||||
import "./SystemStatsArea.css";
|
||||
|
||||
type Severity = "normal" | "warning" | "critical";
|
||||
|
||||
interface SystemSample {
|
||||
cpuPercent: number;
|
||||
usedSystemMemPercent: number;
|
||||
heapUsedPercent: number;
|
||||
}
|
||||
|
||||
const SYSTEM_STATS_POLL_MS = 5_000;
|
||||
const MAX_SYSTEM_SAMPLES = 30;
|
||||
const DEFAULT_TASK_COLUMNS = ["triage", "todo", "in-progress", "in-review", "done"];
|
||||
const AGENT_STATES = ["idle", "active", "running", "error"] as const;
|
||||
|
||||
/*
|
||||
FNXC:CommandCenter 2026-06-18-00:00:
|
||||
System telemetry now lives in the Command Center System area; it polls /api/system-stats (no new endpoint) and keeps a bounded rolling sample buffer to render live CPU/memory trend sparklines.
|
||||
*/
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "—";
|
||||
const mb = bytes / (1024 * 1024);
|
||||
if (mb < 1024) return `${mb.toFixed(0)} MB`;
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function toPercent(used: number, total: number): string {
|
||||
if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) return "—";
|
||||
return `${((used / total) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function heapSeverity(used: number, limit: number): Severity {
|
||||
if (limit <= 0) return "normal";
|
||||
const pct = used / limit;
|
||||
if (pct >= 0.85) return "critical";
|
||||
if (pct >= 0.65) return "warning";
|
||||
return "normal";
|
||||
}
|
||||
|
||||
function rssSeverity(rss: number, totalSystemMem: number): Severity {
|
||||
if (totalSystemMem <= 0) return "normal";
|
||||
const pct = rss / totalSystemMem;
|
||||
if (pct >= 0.5) return "critical";
|
||||
if (pct >= 0.25) return "warning";
|
||||
return "normal";
|
||||
}
|
||||
|
||||
function systemMemSeverity(used: number, total: number): Severity {
|
||||
if (total <= 0) return "normal";
|
||||
const pct = used / total;
|
||||
if (pct >= 0.9) return "critical";
|
||||
if (pct >= 0.75) return "warning";
|
||||
return "normal";
|
||||
}
|
||||
|
||||
function cpuSeverity(percent: number | null, cores: number): Severity {
|
||||
if (percent === null || !Number.isFinite(percent) || percent < 0) return "normal";
|
||||
const normalized = cores > 0 ? percent / cores : percent;
|
||||
if (normalized >= 80) return "critical";
|
||||
if (normalized >= 50) return "warning";
|
||||
return "normal";
|
||||
}
|
||||
|
||||
function severityClassName(severity: Severity): string {
|
||||
if (severity === "critical") return "cc-system-value--critical";
|
||||
if (severity === "warning") return "cc-system-value--warning";
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null | undefined, notYetLabel: string): string {
|
||||
if (!value) return notYetLabel;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return notYetLabel;
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function safeRatio(used: number, total: number): number {
|
||||
if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) return 0;
|
||||
return Math.max(0, Math.min(1, used / total));
|
||||
}
|
||||
|
||||
function clampPercent(value: number | null | undefined): number {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function sampleFromStats(stats: SystemStatsResponse): SystemSample {
|
||||
const system = stats.systemStats;
|
||||
const usedSystemMem = system.systemTotalMem - system.systemFreeMem;
|
||||
return {
|
||||
cpuPercent: clampPercent(system.cpuPercent),
|
||||
usedSystemMemPercent: safeRatio(usedSystemMem, system.systemTotalMem) * 100,
|
||||
heapUsedPercent: safeRatio(system.heapUsed, system.heapLimit) * 100,
|
||||
};
|
||||
}
|
||||
|
||||
export function SystemStatsArea({ projectId }: { projectId?: string }) {
|
||||
const { t } = useTranslation("app");
|
||||
const [stats, setStats] = useState<SystemStatsResponse | null>(null);
|
||||
const [samples, setSamples] = useState<SystemSample[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [autoKillEnabled, setAutoKillEnabled] = useState(true);
|
||||
const [killThreshold, setKillThreshold] = useState(90);
|
||||
const [isKilling, setIsKilling] = useState(false);
|
||||
const [confirmKill, setConfirmKill] = useState(false);
|
||||
const [killResult, setKillResult] = useState<KillVitestResponse | null>(null);
|
||||
const [settingsError, setSettingsError] = useState<string | null>(null);
|
||||
const [lastRefreshedAt, setLastRefreshedAt] = useState<number | null>(null);
|
||||
|
||||
const loadStats = useCallback(async (options?: { preserveKillResult?: boolean }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchSystemStats(projectId);
|
||||
setStats(response);
|
||||
setSamples((prev) => [...prev, sampleFromStats(response)].slice(-MAX_SYSTEM_SAMPLES));
|
||||
setError(null);
|
||||
setLastRefreshedAt(Date.now());
|
||||
if (!options?.preserveKillResult) {
|
||||
setKillResult(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("systemStats.errorLoadStats", "Failed to load system stats"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStats();
|
||||
const timer = window.setInterval(() => {
|
||||
void loadStats();
|
||||
}, SYSTEM_STATS_POLL_MS);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [loadStats]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const settings = await fetchGlobalSettings();
|
||||
if (cancelled) return;
|
||||
setAutoKillEnabled(settings.vitestAutoKillEnabled ?? true);
|
||||
setKillThreshold(settings.vitestKillThresholdPct ?? 90);
|
||||
setSettingsError(null);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setSettingsError(err instanceof Error ? err.message : t("systemStats.errorLoadVitestSettings", "Failed to load vitest settings"));
|
||||
}
|
||||
}
|
||||
};
|
||||
void loadSettings();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const persistAutoKill = useCallback(async (enabled: boolean) => {
|
||||
setAutoKillEnabled(enabled);
|
||||
try {
|
||||
await updateGlobalSettings({ vitestAutoKillEnabled: enabled });
|
||||
setSettingsError(null);
|
||||
} catch (err) {
|
||||
setSettingsError(err instanceof Error ? err.message : t("systemStats.errorSaveVitestSettings", "Failed to save vitest settings"));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const persistKillThreshold = useCallback(async (nextThreshold: number) => {
|
||||
const clamped = Math.min(99, Math.max(50, Number.isFinite(nextThreshold) ? Math.round(nextThreshold) : 90));
|
||||
setKillThreshold(clamped);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ vitestKillThresholdPct: clamped });
|
||||
setSettingsError(null);
|
||||
} catch (err) {
|
||||
setSettingsError(err instanceof Error ? err.message : t("systemStats.errorSaveVitestSettings", "Failed to save vitest settings"));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleKillVitest = useCallback(async () => {
|
||||
if (isKilling) return;
|
||||
if (!confirmKill) {
|
||||
setConfirmKill(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsKilling(true);
|
||||
try {
|
||||
const result = await killVitestProcesses(projectId);
|
||||
setKillResult(result);
|
||||
setConfirmKill(false);
|
||||
await loadStats({ preserveKillResult: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("systemStats.errorKillVitest", "Failed to kill vitest processes"));
|
||||
} finally {
|
||||
setIsKilling(false);
|
||||
}
|
||||
}, [confirmKill, isKilling, loadStats, projectId, t]);
|
||||
|
||||
const system = stats?.systemStats;
|
||||
const taskStats = stats?.taskStats;
|
||||
const usedSystemMem = system ? system.systemTotalMem - system.systemFreeMem : 0;
|
||||
const usedSystemMemRatio = system ? safeRatio(usedSystemMem, system.systemTotalMem) : 0;
|
||||
const heapRatio = system ? safeRatio(system.heapUsed, system.heapLimit) : 0;
|
||||
const cpuRatio = system?.cpuPercent === null || system?.cpuPercent === undefined ? null : clampPercent(system.cpuPercent) / 100;
|
||||
const cpuPercentLabel = system?.cpuPercent === null || system?.cpuPercent === undefined ? t("systemStats.cpuSampling", "Sampling…") : `${system.cpuPercent.toFixed(1)}%`;
|
||||
const refreshLabel = lastRefreshedAt
|
||||
? t("systemStats.updatedAt", "Updated {{time}}", {
|
||||
time: new Date(lastRefreshedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }),
|
||||
})
|
||||
: t("systemStats.waitingFirstUpdate", "Waiting for first update");
|
||||
|
||||
const taskBarData = useMemo<BarDatum[]>(() => {
|
||||
const byColumn = taskStats?.byColumn ?? {};
|
||||
const labels = Object.keys(byColumn).length > 0 ? Object.keys(byColumn) : DEFAULT_TASK_COLUMNS;
|
||||
return labels.map((label) => ({ label, value: byColumn[label] ?? 0, valueLabel: formatCount(byColumn[label] ?? 0) }));
|
||||
}, [taskStats?.byColumn]);
|
||||
|
||||
const agentBarData = useMemo<BarDatum[]>(() => {
|
||||
const agents = taskStats?.agents;
|
||||
return AGENT_STATES.map((state) => ({
|
||||
label: t(`systemStats.agent${state[0].toUpperCase()}${state.slice(1)}`, state),
|
||||
value: agents?.[state] ?? 0,
|
||||
valueLabel: formatCount(agents?.[state] ?? 0),
|
||||
}));
|
||||
}, [taskStats?.agents, t]);
|
||||
|
||||
const detailRows = useMemo(() => {
|
||||
const heapClassName = system ? severityClassName(heapSeverity(system.heapUsed, system.heapLimit)) : "";
|
||||
const rssClassName = system ? severityClassName(rssSeverity(system.rss, system.systemTotalMem)) : "";
|
||||
const systemMemClassName = system ? severityClassName(systemMemSeverity(usedSystemMem, system.systemTotalMem)) : "";
|
||||
const cpuClassName = system ? severityClassName(cpuSeverity(system.cpuPercent, system.cpuCount)) : "";
|
||||
return [
|
||||
{ label: t("systemStats.rowAppCpu", "App CPU"), value: cpuPercentLabel, detail: system?.cpuPercent === null ? t("systemStats.cpuFirstSamplePending", "First sample pending") : t("systemStats.cpuProcessUsage", "process usage"), className: cpuClassName },
|
||||
{ label: t("systemStats.rowRss", "RSS"), value: system ? formatBytes(system.rss) : "—", detail: system ? toPercent(system.rss, system.systemTotalMem) : "—", className: rssClassName },
|
||||
{ label: t("systemStats.rowHeapUsed", "Heap Used"), value: system ? formatBytes(system.heapUsed) : "—", detail: system ? t("systemStats.rowHeapUsedDetail", "of {{total}}", { total: formatBytes(system.heapTotal) }) : "—", className: heapClassName },
|
||||
{ label: t("systemStats.rowHeapLimit", "Heap Limit"), value: system ? formatBytes(system.heapLimit) : "—", detail: t("systemStats.rowHeapLimitDetail", "V8 limit") },
|
||||
{ label: t("systemStats.rowExternal", "External"), value: system ? formatBytes(system.external) : "—" },
|
||||
{ label: t("systemStats.rowArrayBuffers", "Array Buffers"), value: system ? formatBytes(system.arrayBuffers) : "—" },
|
||||
{ label: t("systemStats.rowLoadAvg", "Load Avg"), value: system?.loadAvg.map((value) => value.toFixed(2)).join(" ") ?? "—" },
|
||||
{ label: t("systemStats.rowCores", "Cores"), value: system?.cpuCount ?? "—" },
|
||||
{ label: t("systemStats.rowPlatform", "Platform"), value: system?.platform ?? "—" },
|
||||
{ label: t("systemStats.rowNode", "Node"), value: system?.nodeVersion ?? "—" },
|
||||
{ label: t("systemStats.rowPid", "PID"), value: system?.pid ?? "—" },
|
||||
{ label: t("systemStats.rowMemoryUsed", "Memory Used"), value: system ? formatBytes(usedSystemMem) : "—", detail: system ? `${toPercent(usedSystemMem, system.systemTotalMem)} of ${formatBytes(system.systemTotalMem)}` : "—", className: systemMemClassName },
|
||||
{ label: t("systemStats.rowMemoryFree", "Memory Free"), value: system ? formatBytes(system.systemFreeMem) : "—" },
|
||||
];
|
||||
}, [cpuPercentLabel, system, t, usedSystemMem]);
|
||||
|
||||
const vitestProcessCount = stats?.vitestProcessCount;
|
||||
const lastAutoKillLabel = formatTimestamp(stats?.vitestLastAutoKillAt, t("systemStats.notYet", "Not yet"));
|
||||
const isBackgroundRefreshing = loading && Boolean(stats);
|
||||
|
||||
return (
|
||||
<AreaShell testId="system" isLoading={loading && !stats} error={!stats ? error : null} isEmpty={false}>
|
||||
<div className="cc-area-section">
|
||||
<div className="cc-area-section-header">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.system.healthTitle", "Live system health")}</h3>
|
||||
<div className="cc-system-refresh" aria-live="polite">
|
||||
<span>{t("systemStats.autoRefresh", "Auto-refresh · 5s")}</span>
|
||||
<span>{refreshLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => void loadStats()}
|
||||
title={t("systemStats.refreshTitle", "Refresh")}
|
||||
aria-label={t("systemStats.refreshAriaLabel", "Refresh system stats")}
|
||||
>
|
||||
<RefreshCw size={16} className={isBackgroundRefreshing ? "spin" : undefined} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && stats ? (
|
||||
<p className="cc-system-note cc-system-note--error" role="status">
|
||||
{t("systemStats.footerRefreshFailed", "Latest refresh failed: {{error}}", { error })}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="cc-stat-grid cc-system-gauges">
|
||||
<div className="card cc-stat-card cc-stat-card--gauge" data-testid="cc-system-cpu-gauge">
|
||||
<RadialGauge value={cpuRatio} label={t("systemStats.rowAppCpu", "App CPU")} ariaLabel={t("commandCenter.system.cpuGauge", "App CPU usage")} />
|
||||
<span className="cc-stat-sub">{cpuPercentLabel}</span>
|
||||
</div>
|
||||
<div className="card cc-stat-card cc-stat-card--gauge" data-testid="cc-system-mem-gauge">
|
||||
<RadialGauge value={usedSystemMemRatio} label={t("systemStats.rowMemoryUsed", "Memory Used")} ariaLabel={t("commandCenter.system.memoryGauge", "System memory used")} />
|
||||
<span className="cc-stat-sub">{system ? `${formatBytes(usedSystemMem)} / ${formatBytes(system.systemTotalMem)}` : "—"}</span>
|
||||
</div>
|
||||
<div className="card cc-stat-card cc-stat-card--gauge" data-testid="cc-system-heap-gauge">
|
||||
<RadialGauge value={heapRatio} label={t("systemStats.rowHeapUsed", "Heap Used")} ariaLabel={t("commandCenter.system.heapGauge", "Heap used")} />
|
||||
<span className="cc-stat-sub">{system ? `${formatBytes(system.heapUsed)} / ${formatBytes(system.heapLimit)}` : "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cc-area-section">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.system.trendsTitle", "Live trends")}</h3>
|
||||
<div className="cc-stat-grid">
|
||||
<div className="card cc-stat-card" data-testid="cc-system-cpu-trend">
|
||||
<div className="cc-stat-label">{t("commandCenter.system.cpuTrend", "CPU over time")}</div>
|
||||
<Sparkline values={samples.map((sample) => sample.cpuPercent)} max={100} ariaLabel={t("commandCenter.system.cpuTrend", "CPU over time")} />
|
||||
</div>
|
||||
<div className="card cc-stat-card" data-testid="cc-system-memory-trend">
|
||||
<div className="cc-stat-label">{t("commandCenter.system.memoryTrend", "Memory over time")}</div>
|
||||
<Sparkline values={samples.map((sample) => sample.usedSystemMemPercent)} max={100} ariaLabel={t("commandCenter.system.memoryTrend", "Memory over time")} />
|
||||
</div>
|
||||
<div className="card cc-stat-card" data-testid="cc-system-heap-trend">
|
||||
<div className="cc-stat-label">{t("commandCenter.system.heapTrend", "Heap over time")}</div>
|
||||
<Sparkline values={samples.map((sample) => sample.heapUsedPercent)} max={100} ariaLabel={t("commandCenter.system.heapTrend", "Heap over time")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cc-area-section">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.system.workloadTitle", "Workload")}</h3>
|
||||
<div className="cc-system-chart-grid">
|
||||
<div className="card cc-stat-card" data-testid="cc-system-tasks-bar">
|
||||
<div className="cc-stat-label">{t("systemStats.sectionTasks", "Tasks")}</div>
|
||||
<Bar data={taskBarData} ariaLabel={t("systemStats.sectionTasksAriaLabel", "Task stats")} />
|
||||
</div>
|
||||
<div className="card cc-stat-card" data-testid="cc-system-agents-bar">
|
||||
<div className="cc-stat-label">{t("systemStats.sectionAgents", "Agents")}</div>
|
||||
<Bar data={agentBarData} ariaLabel={t("systemStats.sectionAgentsAriaLabel", "Agent stats")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cc-area-section">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.system.detailsTitle", "Runtime details")}</h3>
|
||||
<div className="cc-stat-grid" data-testid="cc-system-details-grid">
|
||||
{detailRows.map((row) => (
|
||||
<div key={row.label} className="card cc-stat-card">
|
||||
<div className="cc-stat-label">{row.label}</div>
|
||||
<div className={`cc-stat-value ${row.className ?? ""}`.trim()}>{row.value}</div>
|
||||
{row.detail ? <span className="cc-stat-sub">{row.detail}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cc-area-section" data-testid="cc-system-vitest-controls">
|
||||
<h3 className="cc-area-section-title cc-system-section-title-with-icon">
|
||||
<ShieldAlert />
|
||||
<span>{t("systemStats.sectionVitest", "Vitest Controls")}</span>
|
||||
</h3>
|
||||
<div className="cc-stat-grid">
|
||||
<div className="card cc-stat-card">
|
||||
<div className="cc-stat-label">{t("systemStats.vitestProcesses", "Vitest Processes")}</div>
|
||||
<div className="cc-stat-value">{vitestProcessCount ?? "—"}</div>
|
||||
</div>
|
||||
<div className="card cc-stat-card">
|
||||
<div className="cc-stat-label">{t("systemStats.lastAutoKill", "Last auto-kill: {{time}}", { time: "" }).trim()}</div>
|
||||
<div className="cc-stat-value">{lastAutoKillLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card cc-system-vitest-card">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
data-testid="cc-system-kill-vitest"
|
||||
onClick={() => void handleKillVitest()}
|
||||
disabled={isKilling || vitestProcessCount === 0}
|
||||
>
|
||||
<Skull />
|
||||
<span>{confirmKill ? t("systemStats.confirmKill", "Confirm Kill?") : t("systemStats.killVitest", "Kill Vitest Processes")}</span>
|
||||
</button>
|
||||
|
||||
<label className="cc-system-toggle-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoKillEnabled}
|
||||
onChange={(event) => {
|
||||
void persistAutoKill(event.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span>{t("systemStats.autoKillLabel", "Auto-kill vitest on memory pressure")}</span>
|
||||
</label>
|
||||
|
||||
<div className="cc-system-threshold-row">
|
||||
<label htmlFor="cc-system-vitest-threshold-number">{t("systemStats.killThresholdLabel", "Kill threshold (%)")}</label>
|
||||
<div className="cc-system-threshold-controls">
|
||||
<input
|
||||
id="cc-system-vitest-threshold-range"
|
||||
type="range"
|
||||
min={50}
|
||||
max={99}
|
||||
value={killThreshold}
|
||||
aria-label={t("systemStats.killThresholdSliderAriaLabel", "Kill threshold slider (%)")}
|
||||
onChange={(event) => {
|
||||
const nextValue = Number.parseInt(event.target.value, 10);
|
||||
void persistKillThreshold(Number.isNaN(nextValue) ? 90 : nextValue);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
id="cc-system-vitest-threshold-number"
|
||||
type="number"
|
||||
className="input"
|
||||
min={50}
|
||||
max={99}
|
||||
value={killThreshold}
|
||||
aria-label={t("systemStats.killThresholdInputAriaLabel", "Kill threshold (%)")}
|
||||
onChange={(event) => {
|
||||
const nextValue = Number.parseInt(event.target.value, 10);
|
||||
void persistKillThreshold(Number.isNaN(nextValue) ? 90 : nextValue);
|
||||
}}
|
||||
onBlur={() => {
|
||||
void persistKillThreshold(killThreshold);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{killResult ? (
|
||||
<p className={`cc-system-note ${killResult.killed > 0 ? "cc-system-note--success" : "cc-system-note--error"}`}>
|
||||
{t("systemStats.killedProcesses", "Killed {{count}} processes", { count: killResult.killed })}
|
||||
</p>
|
||||
) : null}
|
||||
{settingsError ? <p className="cc-system-note cc-system-note--error">{settingsError}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</AreaShell>
|
||||
);
|
||||
}
|
||||
@@ -205,29 +205,6 @@ describe("useModalManager", () => {
|
||||
expect(result.current.settingsInitialSection).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tracks system stats modal state and includes it in anyModalOpen", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [] }),
|
||||
);
|
||||
|
||||
expect(result.current.systemStatsOpen).toBe(false);
|
||||
expect(result.current.anyModalOpen).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.openSystemStats();
|
||||
});
|
||||
|
||||
expect(result.current.systemStatsOpen).toBe(true);
|
||||
expect(result.current.anyModalOpen).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.closeSystemStats();
|
||||
});
|
||||
|
||||
expect(result.current.systemStatsOpen).toBe(false);
|
||||
expect(result.current.anyModalOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts plain Task object for optimistic modal opening", () => {
|
||||
const task = createTask("FN-456");
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@@ -46,7 +46,6 @@ export interface ModalManager {
|
||||
githubImportOpen: boolean;
|
||||
usageOpen: boolean;
|
||||
usageAnchorRect: DOMRect | null;
|
||||
systemStatsOpen: boolean;
|
||||
terminalOpen: boolean;
|
||||
terminalInitialCommand: string | undefined;
|
||||
terminalInitialCommandGeneration: number;
|
||||
@@ -108,9 +107,6 @@ export interface ModalManager {
|
||||
openUsage: (anchorRect?: DOMRect | null) => void;
|
||||
closeUsage: () => void;
|
||||
|
||||
openSystemStats: () => void;
|
||||
closeSystemStats: () => void;
|
||||
|
||||
toggleTerminal: () => void;
|
||||
closeTerminal: () => void;
|
||||
|
||||
@@ -180,7 +176,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
const [githubImportOpen, setGitHubImportOpen] = useState(false);
|
||||
const [usageOpen, setUsageOpen] = useState(false);
|
||||
const [usageAnchorRect, setUsageAnchorRect] = useState<DOMRect | null>(null);
|
||||
const [systemStatsOpen, setSystemStatsOpen] = useState(false);
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
||||
const [terminalInitialCommandGeneration, setTerminalInitialCommandGeneration] = useState(0);
|
||||
@@ -215,7 +210,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
scriptsOpen ||
|
||||
agentsOpen ||
|
||||
usageOpen ||
|
||||
systemStatsOpen ||
|
||||
schedulesOpen ||
|
||||
githubImportOpen ||
|
||||
setupWizardOpen ||
|
||||
@@ -333,9 +327,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
setUsageAnchorRect(null);
|
||||
}, []);
|
||||
|
||||
const openSystemStats = useCallback(() => setSystemStatsOpen(true), []);
|
||||
const closeSystemStats = useCallback(() => setSystemStatsOpen(false), []);
|
||||
|
||||
const toggleTerminal = useCallback(() => {
|
||||
setTerminalOpen((prev) => !prev);
|
||||
}, []);
|
||||
@@ -445,7 +436,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
githubImportOpen,
|
||||
usageOpen,
|
||||
usageAnchorRect,
|
||||
systemStatsOpen,
|
||||
terminalOpen,
|
||||
terminalInitialCommand,
|
||||
terminalInitialCommandGeneration,
|
||||
@@ -489,8 +479,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
closeGitHubImport,
|
||||
openUsage,
|
||||
closeUsage,
|
||||
openSystemStats,
|
||||
closeSystemStats,
|
||||
toggleTerminal,
|
||||
closeTerminal,
|
||||
openFiles,
|
||||
|
||||
Reference in New Issue
Block a user