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:
|
Key server capabilities:
|
||||||
- REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings
|
- REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings
|
||||||
- System stats snapshot 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
|
- Remote access APIs (`/api/remote/*`) for provider config, activation, tunnel lifecycle, status, token issuance, authenticated URL generation, and QR payload generation
|
||||||
- Operational runbook (prereqs/security/troubleshooting): [`docs/remote-access.md`](./remote-access.md)
|
- Operational runbook (prereqs/security/troubleshooting): [`docs/remote-access.md`](./remote-access.md)
|
||||||
- `/api/remote/tunnel/start`, `/api/remote/tunnel/stop`, and `/api/remote/tunnel/kill-external` cover tunnel lifecycle and external funnel cleanup.
|
- `/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
|
||||||
|
|
||||||
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:
|
Navigation:
|
||||||
- Desktop: **Header → More views → Command Center**
|
- 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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)`.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## Reliability View
|
||||||
|
|||||||
@@ -1335,11 +1335,6 @@ function AppInner() {
|
|||||||
pushNav({ type: "modal", close: modalManager.closeGitManager });
|
pushNav({ type: "modal", close: modalManager.closeGitManager });
|
||||||
}, [modalManager, pushNav]);
|
}, [modalManager, pushNav]);
|
||||||
|
|
||||||
const openSystemStatsWithNav = useCallback(() => {
|
|
||||||
modalManager.openSystemStats();
|
|
||||||
pushNav({ type: "modal", close: modalManager.closeSystemStats });
|
|
||||||
}, [modalManager, pushNav]);
|
|
||||||
|
|
||||||
const openSchedulesWithNav = useCallback(() => {
|
const openSchedulesWithNav = useCallback(() => {
|
||||||
modalManager.openSchedules();
|
modalManager.openSchedules();
|
||||||
pushNav({ type: "modal", close: modalManager.closeSchedules });
|
pushNav({ type: "modal", close: modalManager.closeSchedules });
|
||||||
@@ -1973,7 +1968,6 @@ function AppInner() {
|
|||||||
activePlanningSessionCount={bgPlanningSessions.length}
|
activePlanningSessionCount={bgPlanningSessions.length}
|
||||||
onOpenUsage={openUsageWithNav}
|
onOpenUsage={openUsageWithNav}
|
||||||
onOpenActivityLog={openActivityLogWithNav}
|
onOpenActivityLog={openActivityLogWithNav}
|
||||||
onOpenSystemStats={openSystemStatsWithNav}
|
|
||||||
onOpenMailbox={() => handleTaskViewChange("mailbox")}
|
onOpenMailbox={() => handleTaskViewChange("mailbox")}
|
||||||
mailboxUnreadCount={mailboxUnreadCount}
|
mailboxUnreadCount={mailboxUnreadCount}
|
||||||
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
|
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
|
||||||
@@ -2181,7 +2175,6 @@ function AppInner() {
|
|||||||
keyboardOpen={mobileNavKeyboardOpen}
|
keyboardOpen={mobileNavKeyboardOpen}
|
||||||
onOpenSettings={openSettingsWithNav}
|
onOpenSettings={openSettingsWithNav}
|
||||||
onOpenActivityLog={openActivityLogWithNav}
|
onOpenActivityLog={openActivityLogWithNav}
|
||||||
onOpenSystemStats={openSystemStatsWithNav}
|
|
||||||
onOpenMailbox={() => handleTaskViewChange("mailbox")}
|
onOpenMailbox={() => handleTaskViewChange("mailbox")}
|
||||||
onOpenNodes={handleOpenNodesWithNav}
|
onOpenNodes={handleOpenNodesWithNav}
|
||||||
mailboxUnreadCount={mailboxUnreadCount}
|
mailboxUnreadCount={mailboxUnreadCount}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { TodoModal } from "./TodoModal";
|
|||||||
import { UsageIndicator } from "./UsageIndicator";
|
import { UsageIndicator } from "./UsageIndicator";
|
||||||
import { ScheduledTasksModal } from "./ScheduledTasksModal";
|
import { ScheduledTasksModal } from "./ScheduledTasksModal";
|
||||||
import { NewTaskModal } from "./NewTaskModal";
|
import { NewTaskModal } from "./NewTaskModal";
|
||||||
import { SystemStatsModal } from "./SystemStatsModal";
|
|
||||||
import { ActivityLogModal } from "./ActivityLogModal";
|
import { ActivityLogModal } from "./ActivityLogModal";
|
||||||
import { GitManagerModal } from "./GitManagerModal";
|
import { GitManagerModal } from "./GitManagerModal";
|
||||||
import { AgentListModal } from "./AgentListModal";
|
import { AgentListModal } from "./AgentListModal";
|
||||||
@@ -187,11 +186,6 @@ export function AppModals({
|
|||||||
modalManager.closeUsage();
|
modalManager.closeUsage();
|
||||||
}, [modalManager.closeUsage, removeNav]);
|
}, [modalManager.closeUsage, removeNav]);
|
||||||
|
|
||||||
const closeSystemStatsWithNav = useCallback(() => {
|
|
||||||
removeNav(modalManager.closeSystemStats);
|
|
||||||
modalManager.closeSystemStats();
|
|
||||||
}, [modalManager.closeSystemStats, removeNav]);
|
|
||||||
|
|
||||||
const closeSchedulesWithNav = useCallback(() => {
|
const closeSchedulesWithNav = useCallback(() => {
|
||||||
removeNav(modalManager.closeSchedules);
|
removeNav(modalManager.closeSchedules);
|
||||||
modalManager.closeSchedules();
|
modalManager.closeSchedules();
|
||||||
@@ -426,12 +420,6 @@ export function AppModals({
|
|||||||
anchorRect={modalManager.usageAnchorRect}
|
anchorRect={modalManager.usageAnchorRect}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SystemStatsModal
|
|
||||||
isOpen={modalManager.systemStatsOpen}
|
|
||||||
onClose={closeSystemStatsWithNav}
|
|
||||||
projectId={projectId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{modalManager.schedulesOpen && (
|
{modalManager.schedulesOpen && (
|
||||||
<ScheduledTasksModal
|
<ScheduledTasksModal
|
||||||
onClose={closeSchedulesWithNav}
|
onClose={closeSchedulesWithNav}
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export interface HeaderProps {
|
|||||||
activePlanningSessionCount?: number;
|
activePlanningSessionCount?: number;
|
||||||
onOpenUsage?: (anchorRect?: DOMRect | null) => void;
|
onOpenUsage?: (anchorRect?: DOMRect | null) => void;
|
||||||
onOpenActivityLog?: () => void;
|
onOpenActivityLog?: () => void;
|
||||||
onOpenSystemStats?: () => void;
|
|
||||||
/** Opens the mailbox view */
|
/** Opens the mailbox view */
|
||||||
onOpenMailbox?: () => void;
|
onOpenMailbox?: () => void;
|
||||||
/** Unread message count for badge display */
|
/** Unread message count for badge display */
|
||||||
@@ -140,7 +139,6 @@ export function Header({
|
|||||||
activePlanningSessionCount = 0,
|
activePlanningSessionCount = 0,
|
||||||
onOpenUsage,
|
onOpenUsage,
|
||||||
onOpenActivityLog,
|
onOpenActivityLog,
|
||||||
onOpenSystemStats,
|
|
||||||
onOpenMailbox,
|
onOpenMailbox,
|
||||||
mailboxUnreadCount = 0,
|
mailboxUnreadCount = 0,
|
||||||
mailboxPendingApprovalCount = 0,
|
mailboxPendingApprovalCount = 0,
|
||||||
@@ -1314,13 +1312,6 @@ export function Header({
|
|||||||
</button>
|
</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) */}
|
{/* Activity Log button - desktop only (moved to overflow on mobile/tablet) */}
|
||||||
{!isCompact && onOpenActivityLog && (
|
{!isCompact && onOpenActivityLog && (
|
||||||
<button className="btn-icon" onClick={onOpenActivityLog} title={t("header.viewActivityLog", "View Activity Log")}>
|
<button className="btn-icon" onClick={onOpenActivityLog} title={t("header.viewActivityLog", "View Activity Log")}>
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ export interface MobileNavBarProps {
|
|||||||
// Navigation handlers
|
// Navigation handlers
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
onOpenActivityLog?: () => void;
|
onOpenActivityLog?: () => void;
|
||||||
onOpenSystemStats?: () => void;
|
|
||||||
onOpenMailbox?: () => void;
|
onOpenMailbox?: () => void;
|
||||||
mailboxUnreadCount?: number;
|
mailboxUnreadCount?: number;
|
||||||
mailboxPendingApprovalCount?: number;
|
mailboxPendingApprovalCount?: number;
|
||||||
@@ -142,7 +141,6 @@ export function MobileNavBar({
|
|||||||
keyboardOpen = false,
|
keyboardOpen = false,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onOpenActivityLog,
|
onOpenActivityLog,
|
||||||
onOpenSystemStats,
|
|
||||||
onOpenMailbox,
|
onOpenMailbox,
|
||||||
mailboxUnreadCount = 0,
|
mailboxUnreadCount = 0,
|
||||||
mailboxPendingApprovalCount = 0,
|
mailboxPendingApprovalCount = 0,
|
||||||
@@ -484,16 +482,6 @@ export function MobileNavBar({
|
|||||||
<span>{t("nav.activityLog", "Activity Log")}</span>
|
<span>{t("nav.activityLog", "Activity Log")}</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mobile-more-item"
|
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,
|
NewTaskModal: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockSystemStatsModalProps = vi.fn();
|
|
||||||
vi.mock("../SystemStatsModal", () => ({
|
|
||||||
SystemStatsModal: (props: any) => {
|
|
||||||
mockSystemStatsModalProps(props);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockActivityLogModalProps = vi.fn();
|
const mockActivityLogModalProps = vi.fn();
|
||||||
vi.mock("../ActivityLogModal", () => ({
|
vi.mock("../ActivityLogModal", () => ({
|
||||||
ActivityLogModal: (props: any) => {
|
ActivityLogModal: (props: any) => {
|
||||||
@@ -182,7 +174,6 @@ describe("AppModals", () => {
|
|||||||
fileBrowserInitialFile: null,
|
fileBrowserInitialFile: null,
|
||||||
usageOpen: false,
|
usageOpen: false,
|
||||||
usageAnchorRect: null,
|
usageAnchorRect: null,
|
||||||
systemStatsOpen: false,
|
|
||||||
schedulesOpen: false,
|
schedulesOpen: false,
|
||||||
newTaskModalOpen: false,
|
newTaskModalOpen: false,
|
||||||
activityLogOpen: false,
|
activityLogOpen: false,
|
||||||
@@ -220,8 +211,6 @@ describe("AppModals", () => {
|
|||||||
setFileWorkspace: vi.fn(),
|
setFileWorkspace: vi.fn(),
|
||||||
openUsage: vi.fn(),
|
openUsage: vi.fn(),
|
||||||
closeUsage: vi.fn(),
|
closeUsage: vi.fn(),
|
||||||
openSystemStats: vi.fn(),
|
|
||||||
closeSystemStats: vi.fn(),
|
|
||||||
openSchedules: vi.fn(),
|
openSchedules: vi.fn(),
|
||||||
closeSchedules: vi.fn(),
|
closeSchedules: vi.fn(),
|
||||||
openNewTask: vi.fn(),
|
openNewTask: vi.fn(),
|
||||||
@@ -257,7 +246,6 @@ describe("AppModals", () => {
|
|||||||
mockModelOnboardingModalProps.mockClear();
|
mockModelOnboardingModalProps.mockClear();
|
||||||
mockActivityLogModalProps.mockClear();
|
mockActivityLogModalProps.mockClear();
|
||||||
mockSettingsModalProps.mockClear();
|
mockSettingsModalProps.mockClear();
|
||||||
mockSystemStatsModalProps.mockClear();
|
|
||||||
mockTodoModalProps.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", () => {
|
describe("task detail history wiring", () => {
|
||||||
const commonProps = {
|
const commonProps = {
|
||||||
projectId: "proj-1",
|
projectId: "proj-1",
|
||||||
|
|||||||
@@ -113,18 +113,6 @@ describe("Header", () => {
|
|||||||
expect(screen.getByTitle("Import from GitHub")).toBeDefined();
|
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", () => {
|
it("calls onOpenSettings when settings button is clicked", () => {
|
||||||
const onOpenSettings = vi.fn();
|
const onOpenSettings = vi.fn();
|
||||||
renderHeader({ onOpenSettings });
|
renderHeader({ onOpenSettings });
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ const createDefaultProps = () => ({
|
|||||||
modalOpen: false,
|
modalOpen: false,
|
||||||
onOpenSettings: vi.fn(),
|
onOpenSettings: vi.fn(),
|
||||||
onOpenActivityLog: vi.fn(),
|
onOpenActivityLog: vi.fn(),
|
||||||
onOpenSystemStats: vi.fn(),
|
|
||||||
onOpenMailbox: vi.fn(),
|
onOpenMailbox: vi.fn(),
|
||||||
onOpenNodes: vi.fn(),
|
onOpenNodes: vi.fn(),
|
||||||
mailboxUnreadCount: 0,
|
mailboxUnreadCount: 0,
|
||||||
@@ -393,7 +392,6 @@ describe("MobileNavBar", () => {
|
|||||||
|
|
||||||
expect(screen.getByTestId("mobile-more-item-mailbox")).toBeDefined();
|
expect(screen.getByTestId("mobile-more-item-mailbox")).toBeDefined();
|
||||||
expect(screen.getByTestId("mobile-more-item-activity")).toBeDefined();
|
expect(screen.getByTestId("mobile-more-item-activity")).toBeDefined();
|
||||||
expect(screen.getByTestId("mobile-more-item-system-stats")).toBeDefined();
|
|
||||||
expect(screen.getByTestId("mobile-more-item-git")).toBeDefined();
|
expect(screen.getByTestId("mobile-more-item-git")).toBeDefined();
|
||||||
expect(screen.getByTestId("mobile-more-item-terminal")).toBeDefined();
|
expect(screen.getByTestId("mobile-more-item-terminal")).toBeDefined();
|
||||||
expect(screen.getByTestId("mobile-more-item-files")).toBeDefined();
|
expect(screen.getByTestId("mobile-more-item-files")).toBeDefined();
|
||||||
@@ -563,17 +561,6 @@ describe("MobileNavBar", () => {
|
|||||||
expect(props.onOpenActivityLog).toHaveBeenCalledOnce();
|
expect(props.onOpenActivityLog).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("system stats item in more sheet calls onOpenSystemStats", () => {
|
|
||||||
const props = createDefaultProps();
|
|
||||||
const { container } = render(<MobileNavBar {...props} />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
|
||||||
fireEvent.click(screen.getByTestId("mobile-more-item-system-stats"));
|
|
||||||
|
|
||||||
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
|
|
||||||
expect(props.onOpenSystemStats).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes sheet and calls handler when item is clicked", () => {
|
it("closes sheet and calls handler when item is clicked", () => {
|
||||||
const props = createDefaultProps();
|
const props = createDefaultProps();
|
||||||
const { container } = render(<MobileNavBar {...props} />);
|
const { container } = render(<MobileNavBar {...props} />);
|
||||||
|
|||||||
@@ -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}
|
keyboardOpen={keyboardOpen}
|
||||||
onOpenSettings={vi.fn()}
|
onOpenSettings={vi.fn()}
|
||||||
onOpenActivityLog={vi.fn()}
|
onOpenActivityLog={vi.fn()}
|
||||||
onOpenSystemStats={vi.fn()}
|
|
||||||
onOpenMailbox={vi.fn()}
|
onOpenMailbox={vi.fn()}
|
||||||
onOpenGitManager={vi.fn()}
|
onOpenGitManager={vi.fn()}
|
||||||
onOpenWorkflowEditor={vi.fn()}
|
onOpenWorkflowEditor={vi.fn()}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { TeamArea } from "./areas/TeamArea";
|
|||||||
import { EcosystemArea } from "./areas/EcosystemArea";
|
import { EcosystemArea } from "./areas/EcosystemArea";
|
||||||
import { GithubArea } from "./areas/GithubArea";
|
import { GithubArea } from "./areas/GithubArea";
|
||||||
import { SignalsArea } from "./areas/SignalsArea";
|
import { SignalsArea } from "./areas/SignalsArea";
|
||||||
|
import { SystemStatsArea } from "./areas/SystemStatsArea";
|
||||||
import { MissionControlPanel } from "./MissionControlPanel";
|
import { MissionControlPanel } from "./MissionControlPanel";
|
||||||
import { SdlcFunnel } from "./SdlcFunnel";
|
import { SdlcFunnel } from "./SdlcFunnel";
|
||||||
import { Bar, type BarDatum } from "./charts/Bar";
|
import { Bar, type BarDatum } from "./charts/Bar";
|
||||||
@@ -31,6 +32,7 @@ type SubViewId =
|
|||||||
| "ecosystem"
|
| "ecosystem"
|
||||||
| "github"
|
| "github"
|
||||||
| "signals"
|
| "signals"
|
||||||
|
| "system"
|
||||||
| "mission-control";
|
| "mission-control";
|
||||||
|
|
||||||
interface SubView {
|
interface SubView {
|
||||||
@@ -54,6 +56,7 @@ function useSubViews(): SubView[] {
|
|||||||
{ id: "ecosystem", label: t("commandCenter.tabs.ecosystem", "Ecosystem") },
|
{ id: "ecosystem", label: t("commandCenter.tabs.ecosystem", "Ecosystem") },
|
||||||
{ id: "github", label: t("commandCenter.tabs.github", "GitHub") },
|
{ id: "github", label: t("commandCenter.tabs.github", "GitHub") },
|
||||||
{ id: "signals", label: t("commandCenter.tabs.signals", "Signals") },
|
{ 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") },
|
{ id: "mission-control", label: t("commandCenter.tabs.missionControl", "Mission Control") },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -441,6 +444,8 @@ export function CommandCenter() {
|
|||||||
return <GithubArea range={range} />;
|
return <GithubArea range={range} />;
|
||||||
case "signals":
|
case "signals":
|
||||||
return <SignalsArea range={range} />;
|
return <SignalsArea range={range} />;
|
||||||
|
case "system":
|
||||||
|
return <SystemStatsArea />;
|
||||||
case "mission-control":
|
case "mission-control":
|
||||||
return <MissionControlPanel />;
|
return <MissionControlPanel />;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ vi.mock("../../../api/legacy", () => ({
|
|||||||
api: (path: string, opts?: RequestInit) => apiMock(path, opts),
|
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() {
|
function emptyTokenFixture() {
|
||||||
return {
|
return {
|
||||||
totals: { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0 },
|
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 } = {}) {
|
function mockOverviewApi({ populated = false }: { populated?: boolean } = {}) {
|
||||||
apiMock.mockImplementation((path: string) => {
|
apiMock.mockImplementation((path: string) => {
|
||||||
if (path.startsWith("/command-center/tokens")) return Promise.resolve(populated ? populatedTokenFixture() : emptyTokenFixture());
|
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/github")) return Promise.resolve(emptyGithubFixture());
|
||||||
if (path.startsWith("/command-center/team")) return Promise.resolve(emptyTeamFixture());
|
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.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") {
|
if (path === "/command-center/live") {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
capturedAt: "2026-06-18T00:00:00.000Z",
|
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");
|
const githubPanel = screen.getByTestId("command-center-panel-github");
|
||||||
expect(githubPanel).toBe(screen.getByRole("tabpanel"));
|
expect(githubPanel).toBe(screen.getByRole("tabpanel"));
|
||||||
assertScrollOwnerContract(githubPanel);
|
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 () => {
|
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),
|
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) {
|
function tokenFixture(totalTokens = 1_500) {
|
||||||
return {
|
return {
|
||||||
from: "2026-06-08",
|
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({
|
function mockOverviewApi({
|
||||||
tokens = tokenFixture(),
|
tokens = tokenFixture(),
|
||||||
tools = toolsFixture(),
|
tools = toolsFixture(),
|
||||||
@@ -211,6 +249,8 @@ function mockOverviewApi({
|
|||||||
if (path === "/command-center/live") {
|
if (path === "/command-center/live") {
|
||||||
return live instanceof Error ? Promise.reject(live) : Promise.resolve(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}`));
|
return Promise.reject(new Error(`Unhandled api path: ${path}`));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -520,8 +560,8 @@ describe("CommandCenter shell", () => {
|
|||||||
render(<CommandCenter />);
|
render(<CommandCenter />);
|
||||||
const tablist = screen.getByRole("tablist");
|
const tablist = screen.getByRole("tablist");
|
||||||
const tabs = within(tablist).getAllByRole("tab");
|
const tabs = within(tablist).getAllByRole("tab");
|
||||||
// Overview, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, Mission Control.
|
// Overview, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, System, Mission Control.
|
||||||
expect(tabs.length).toBe(10);
|
expect(tabs.length).toBe(11);
|
||||||
// roving tabindex: exactly one tab is focusable.
|
// roving tabindex: exactly one tab is focusable.
|
||||||
const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0");
|
const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0");
|
||||||
expect(focusable.length).toBe(1);
|
expect(focusable.length).toBe(1);
|
||||||
@@ -536,6 +576,18 @@ describe("CommandCenter shell", () => {
|
|||||||
expect(screen.getByTestId("command-center-panel-tokens")).toBeTruthy();
|
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 () => {
|
it("renders and routes the GitHub tab exactly once", async () => {
|
||||||
mockOverviewApi({ github: githubFixture(4, 2) });
|
mockOverviewApi({ github: githubFixture(4, 2) });
|
||||||
render(<CommandCenter />);
|
render(<CommandCenter />);
|
||||||
@@ -642,6 +694,7 @@ describe("CommandCenter shell", () => {
|
|||||||
"ecosystem",
|
"ecosystem",
|
||||||
"github",
|
"github",
|
||||||
"signals",
|
"signals",
|
||||||
|
"system",
|
||||||
"mission-control",
|
"mission-control",
|
||||||
"team",
|
"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();
|
expect(result.current.settingsInitialSection).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks system stats modal state and includes it in anyModalOpen", () => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useModalManager({ projectId: "proj_1", planningSessions: [] }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current.systemStatsOpen).toBe(false);
|
|
||||||
expect(result.current.anyModalOpen).toBe(false);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.openSystemStats();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.systemStatsOpen).toBe(true);
|
|
||||||
expect(result.current.anyModalOpen).toBe(true);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.closeSystemStats();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.systemStatsOpen).toBe(false);
|
|
||||||
expect(result.current.anyModalOpen).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts plain Task object for optimistic modal opening", () => {
|
it("accepts plain Task object for optimistic modal opening", () => {
|
||||||
const task = createTask("FN-456");
|
const task = createTask("FN-456");
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export interface ModalManager {
|
|||||||
githubImportOpen: boolean;
|
githubImportOpen: boolean;
|
||||||
usageOpen: boolean;
|
usageOpen: boolean;
|
||||||
usageAnchorRect: DOMRect | null;
|
usageAnchorRect: DOMRect | null;
|
||||||
systemStatsOpen: boolean;
|
|
||||||
terminalOpen: boolean;
|
terminalOpen: boolean;
|
||||||
terminalInitialCommand: string | undefined;
|
terminalInitialCommand: string | undefined;
|
||||||
terminalInitialCommandGeneration: number;
|
terminalInitialCommandGeneration: number;
|
||||||
@@ -108,9 +107,6 @@ export interface ModalManager {
|
|||||||
openUsage: (anchorRect?: DOMRect | null) => void;
|
openUsage: (anchorRect?: DOMRect | null) => void;
|
||||||
closeUsage: () => void;
|
closeUsage: () => void;
|
||||||
|
|
||||||
openSystemStats: () => void;
|
|
||||||
closeSystemStats: () => void;
|
|
||||||
|
|
||||||
toggleTerminal: () => void;
|
toggleTerminal: () => void;
|
||||||
closeTerminal: () => void;
|
closeTerminal: () => void;
|
||||||
|
|
||||||
@@ -180,7 +176,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
const [githubImportOpen, setGitHubImportOpen] = useState(false);
|
const [githubImportOpen, setGitHubImportOpen] = useState(false);
|
||||||
const [usageOpen, setUsageOpen] = useState(false);
|
const [usageOpen, setUsageOpen] = useState(false);
|
||||||
const [usageAnchorRect, setUsageAnchorRect] = useState<DOMRect | null>(null);
|
const [usageAnchorRect, setUsageAnchorRect] = useState<DOMRect | null>(null);
|
||||||
const [systemStatsOpen, setSystemStatsOpen] = useState(false);
|
|
||||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||||
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
||||||
const [terminalInitialCommandGeneration, setTerminalInitialCommandGeneration] = useState(0);
|
const [terminalInitialCommandGeneration, setTerminalInitialCommandGeneration] = useState(0);
|
||||||
@@ -215,7 +210,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
scriptsOpen ||
|
scriptsOpen ||
|
||||||
agentsOpen ||
|
agentsOpen ||
|
||||||
usageOpen ||
|
usageOpen ||
|
||||||
systemStatsOpen ||
|
|
||||||
schedulesOpen ||
|
schedulesOpen ||
|
||||||
githubImportOpen ||
|
githubImportOpen ||
|
||||||
setupWizardOpen ||
|
setupWizardOpen ||
|
||||||
@@ -333,9 +327,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
setUsageAnchorRect(null);
|
setUsageAnchorRect(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openSystemStats = useCallback(() => setSystemStatsOpen(true), []);
|
|
||||||
const closeSystemStats = useCallback(() => setSystemStatsOpen(false), []);
|
|
||||||
|
|
||||||
const toggleTerminal = useCallback(() => {
|
const toggleTerminal = useCallback(() => {
|
||||||
setTerminalOpen((prev) => !prev);
|
setTerminalOpen((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -445,7 +436,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
githubImportOpen,
|
githubImportOpen,
|
||||||
usageOpen,
|
usageOpen,
|
||||||
usageAnchorRect,
|
usageAnchorRect,
|
||||||
systemStatsOpen,
|
|
||||||
terminalOpen,
|
terminalOpen,
|
||||||
terminalInitialCommand,
|
terminalInitialCommand,
|
||||||
terminalInitialCommandGeneration,
|
terminalInitialCommandGeneration,
|
||||||
@@ -489,8 +479,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
closeGitHubImport,
|
closeGitHubImport,
|
||||||
openUsage,
|
openUsage,
|
||||||
closeUsage,
|
closeUsage,
|
||||||
openSystemStats,
|
|
||||||
closeSystemStats,
|
|
||||||
toggleTerminal,
|
toggleTerminal,
|
||||||
closeTerminal,
|
closeTerminal,
|
||||||
openFiles,
|
openFiles,
|
||||||
|
|||||||
Reference in New Issue
Block a user