feat(FN-2681): add Vitest kill controls in system stats
- Add dashboard API endpoint and server route to terminate Vitest worker processes safely - Extend legacy API client with killVitestProcess support for frontend invocation - Add SystemStatsModal UI controls and styling for triggering Vitest kill actions, including mobile width stability fixes - Expand route and modal tests to cover new controls and behavior - Add changeset and architecture docs updates for the new Vitest kill capability
This commit is contained in:
5
.changeset/add-vitest-kill-controls.md
Normal file
5
.changeset/add-vitest-kill-controls.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add dashboard vitest process controls with a new `POST /api/kill-vitest` endpoint and System Stats modal UI for manual kills plus auto-kill settings management.
|
||||||
@@ -396,7 +396,7 @@ Operator setup + troubleshooting guide: **[Remote Access runbook](./remote-acces
|
|||||||
|
|
||||||
Key server capabilities:
|
Key server capabilities:
|
||||||
- REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings
|
- REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings
|
||||||
- System stats snapshot API (`GET /api/system-stats`) exposing dashboard process/system telemetry plus task and agent aggregates for the System Stats modal
|
- System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry plus task/agent aggregates and manual vitest process termination for the System Stats modal
|
||||||
- Remote access APIs (`/api/remote/*`) for provider config, activation, tunnel lifecycle, status, token issuance, authenticated URL generation, and QR payload generation
|
- Remote access APIs (`/api/remote/*`) for provider config, activation, tunnel lifecycle, status, token issuance, authenticated URL generation, and QR payload generation
|
||||||
- Operational runbook (prereqs/security/troubleshooting): [`docs/remote-access.md`](./remote-access.md)
|
- Operational runbook (prereqs/security/troubleshooting): [`docs/remote-access.md`](./remote-access.md)
|
||||||
- `/api/remote/tunnel/start` and `/api/remote/tunnel/stop` are the only lifecycle transition endpoints.
|
- `/api/remote/tunnel/start` and `/api/remote/tunnel/stop` are the only lifecycle transition endpoints.
|
||||||
|
|||||||
@@ -4896,12 +4896,25 @@ export interface TaskStatsSnapshot {
|
|||||||
export interface SystemStatsResponse {
|
export interface SystemStatsResponse {
|
||||||
systemStats: SystemStatsSnapshot;
|
systemStats: SystemStatsSnapshot;
|
||||||
taskStats: TaskStatsSnapshot;
|
taskStats: TaskStatsSnapshot;
|
||||||
|
vitestProcessCount?: number;
|
||||||
|
vitestLastAutoKillAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KillVitestResponse {
|
||||||
|
killed: number;
|
||||||
|
pids: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchSystemStats(projectId?: string): Promise<SystemStatsResponse> {
|
export function fetchSystemStats(projectId?: string): Promise<SystemStatsResponse> {
|
||||||
return api<SystemStatsResponse>(withProjectId("/system-stats", projectId));
|
return api<SystemStatsResponse>(withProjectId("/system-stats", projectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function killVitestProcesses(projectId?: string): Promise<KillVitestResponse> {
|
||||||
|
return api<KillVitestResponse>(withProjectId("/kill-vitest", projectId), {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetch unified activity feed */
|
/** Fetch unified activity feed */
|
||||||
export function fetchActivityFeed(options?: FeedOptions): Promise<ActivityFeedEntry[]> {
|
export function fetchActivityFeed(options?: FeedOptions): Promise<ActivityFeedEntry[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|||||||
@@ -114,6 +114,70 @@
|
|||||||
font-size: 0.85rem;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.system-stats-modal {
|
.system-stats-modal {
|
||||||
max-height: min(84vh, 60rem);
|
max-height: min(84vh, 60rem);
|
||||||
@@ -129,4 +193,24 @@
|
|||||||
margin-left: var(--space-lg);
|
margin-left: var(--space-lg);
|
||||||
margin-right: var(--space-lg);
|
margin-right: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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,6 +1,13 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Monitor, RefreshCw, X } from "lucide-react";
|
import { Monitor, RefreshCw, ShieldAlert, Skull, X } from "lucide-react";
|
||||||
import { fetchSystemStats, type SystemStatsResponse } from "../api";
|
import {
|
||||||
|
fetchGlobalSettings,
|
||||||
|
fetchSystemStats,
|
||||||
|
killVitestProcesses,
|
||||||
|
updateGlobalSettings,
|
||||||
|
type KillVitestResponse,
|
||||||
|
type SystemStatsResponse,
|
||||||
|
} from "../api";
|
||||||
import "./SystemStatsModal.css";
|
import "./SystemStatsModal.css";
|
||||||
|
|
||||||
interface SystemStatsModalProps {
|
interface SystemStatsModalProps {
|
||||||
@@ -53,6 +60,13 @@ function severityClassName(severity: Severity): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value: string | null | undefined): string {
|
||||||
|
if (!value) return "Not yet";
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return "Not yet";
|
||||||
|
return parsed.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SystemStatsModal groups dashboard runtime telemetry into five sections:
|
* SystemStatsModal groups dashboard runtime telemetry into five sections:
|
||||||
* process memory metrics, CPU/load information, host memory usage, task counts
|
* process memory metrics, CPU/load information, host memory usage, task counts
|
||||||
@@ -62,13 +76,22 @@ export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModa
|
|||||||
const [stats, setStats] = useState<SystemStatsResponse | null>(null);
|
const [stats, setStats] = useState<SystemStatsResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 loadStats = useCallback(async () => {
|
const loadStats = useCallback(async (options?: { preserveKillResult?: boolean }) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetchSystemStats(projectId);
|
const response = await fetchSystemStats(projectId);
|
||||||
setStats(response);
|
setStats(response);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (!options?.preserveKillResult) {
|
||||||
|
setKillResult(null);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load system stats");
|
setError(err instanceof Error ? err.message : "Failed to load system stats");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -89,6 +112,23 @@ export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModa
|
|||||||
};
|
};
|
||||||
}, [isOpen, loadStats]);
|
}, [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 : "Failed to load vitest settings");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSettings();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
const onKeydown = (event: KeyboardEvent) => {
|
const onKeydown = (event: KeyboardEvent) => {
|
||||||
@@ -112,6 +152,48 @@ export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModa
|
|||||||
];
|
];
|
||||||
}, [stats]);
|
}, [stats]);
|
||||||
|
|
||||||
|
const persistAutoKill = useCallback(async (enabled: boolean) => {
|
||||||
|
setAutoKillEnabled(enabled);
|
||||||
|
try {
|
||||||
|
await updateGlobalSettings({ vitestAutoKillEnabled: enabled });
|
||||||
|
setSettingsError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setSettingsError(err instanceof Error ? err.message : "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 : "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 : "Failed to kill vitest processes");
|
||||||
|
} finally {
|
||||||
|
setIsKilling(false);
|
||||||
|
}
|
||||||
|
}, [confirmKill, isKilling, loadStats, projectId]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const system = stats?.systemStats;
|
const system = stats?.systemStats;
|
||||||
@@ -120,6 +202,13 @@ export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModa
|
|||||||
const usedSystemClassName = system
|
const usedSystemClassName = system
|
||||||
? severityClassName(systemMemSeverity(usedSystemMem, system.systemTotalMem))
|
? severityClassName(systemMemSeverity(usedSystemMem, system.systemTotalMem))
|
||||||
: "";
|
: "";
|
||||||
|
const vitestProcessCount = stats?.vitestProcessCount;
|
||||||
|
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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -263,6 +352,82 @@ export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModa
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="system-stats-modal__section" aria-label="Vitest controls">
|
||||||
|
<h3 className="system-stats-modal__section-title system-stats-modal__section-title--with-icon">
|
||||||
|
<ShieldAlert />
|
||||||
|
<span>Vitest Controls</span>
|
||||||
|
</h3>
|
||||||
|
<dl className="system-stats-modal__grid system-stats-modal__vitest-controls">
|
||||||
|
<div className="system-stats-modal__row">
|
||||||
|
<dt>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 ? "Confirm Kill?" : "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>Auto-kill vitest on memory pressure</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="system-stats-modal__threshold-row">
|
||||||
|
<label htmlFor="vitest-threshold-number">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="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="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}>Killed {killResult.killed} processes</p>}
|
||||||
|
<p className="system-stats-modal__last-kill">Last auto-kill: {lastAutoKillLabel}</p>
|
||||||
|
{settingsError && <p className="system-stats-modal__kill-result system-stats-modal__kill-result--error">{settingsError}</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { SystemStatsModal } from "../SystemStatsModal";
|
import { SystemStatsModal } from "../SystemStatsModal";
|
||||||
|
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
Monitor: () => <span data-testid="icon-monitor" />,
|
Monitor: () => <span data-testid="icon-monitor" />,
|
||||||
RefreshCw: () => <span data-testid="icon-refresh" />,
|
RefreshCw: () => <span data-testid="icon-refresh" />,
|
||||||
|
ShieldAlert: () => <span data-testid="icon-shield-alert" />,
|
||||||
|
Skull: () => <span data-testid="icon-skull" />,
|
||||||
X: () => <span data-testid="icon-x" />,
|
X: () => <span data-testid="icon-x" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockFetchSystemStats = vi.fn();
|
const mockFetchSystemStats = vi.fn();
|
||||||
|
const mockFetchGlobalSettings = vi.fn();
|
||||||
|
const mockKillVitestProcesses = vi.fn();
|
||||||
|
const mockUpdateGlobalSettings = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchSystemStats: (...args: unknown[]) => mockFetchSystemStats(...args),
|
fetchSystemStats: (...args: unknown[]) => mockFetchSystemStats(...args),
|
||||||
|
fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args),
|
||||||
|
killVitestProcesses: (...args: unknown[]) => mockKillVitestProcesses(...args),
|
||||||
|
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const sampleStats = {
|
const sampleStats = {
|
||||||
@@ -49,11 +57,20 @@ const sampleStats = {
|
|||||||
error: 1,
|
error: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
vitestProcessCount: 2,
|
||||||
|
vitestLastAutoKillAt: "2026-04-27T12:00:00.000Z",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("SystemStatsModal", () => {
|
describe("SystemStatsModal", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockFetchSystemStats.mockResolvedValue(sampleStats);
|
||||||
|
mockFetchGlobalSettings.mockResolvedValue({
|
||||||
|
vitestAutoKillEnabled: true,
|
||||||
|
vitestKillThresholdPct: 90,
|
||||||
|
});
|
||||||
|
mockKillVitestProcesses.mockResolvedValue({ killed: 2, pids: [111, 222] });
|
||||||
|
mockUpdateGlobalSettings.mockResolvedValue({});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -69,12 +86,11 @@ describe("SystemStatsModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders fetched metrics across all sections", async () => {
|
it("renders fetched metrics across all sections", async () => {
|
||||||
mockFetchSystemStats.mockResolvedValue(sampleStats);
|
|
||||||
|
|
||||||
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} projectId="proj-1" />);
|
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} projectId="proj-1" />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockFetchSystemStats).toHaveBeenCalledWith("proj-1");
|
expect(mockFetchSystemStats).toHaveBeenCalledWith("proj-1");
|
||||||
|
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText("System Stats")).toBeDefined();
|
expect(screen.getByText("System Stats")).toBeDefined();
|
||||||
@@ -83,12 +99,15 @@ describe("SystemStatsModal", () => {
|
|||||||
expect(screen.getByText("System")).toBeDefined();
|
expect(screen.getByText("System")).toBeDefined();
|
||||||
expect(screen.getByText("Tasks")).toBeDefined();
|
expect(screen.getByText("Tasks")).toBeDefined();
|
||||||
expect(screen.getByText("Agents")).toBeDefined();
|
expect(screen.getByText("Agents")).toBeDefined();
|
||||||
|
expect(screen.getByText("Vitest Controls")).toBeDefined();
|
||||||
|
|
||||||
expect(screen.getByText("5.00 GB")).toBeDefined();
|
expect(screen.getByText("5.00 GB")).toBeDefined();
|
||||||
expect(screen.getByText("900 MB")).toBeDefined();
|
expect(screen.getByText("900 MB")).toBeDefined();
|
||||||
expect(screen.getByText("9.00 GB")).toBeDefined();
|
expect(screen.getByText("9.00 GB")).toBeDefined();
|
||||||
expect(screen.getByText("90.0% of 10.00 GB")).toBeDefined();
|
expect(screen.getByText("90.0% of 10.00 GB")).toBeDefined();
|
||||||
expect(screen.getByText("1.20 0.80 0.50")).toBeDefined();
|
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");
|
const criticalValues = document.querySelectorAll(".system-stats-modal__value--critical");
|
||||||
expect(criticalValues.length).toBeGreaterThan(0);
|
expect(criticalValues.length).toBeGreaterThan(0);
|
||||||
@@ -102,9 +121,76 @@ describe("SystemStatsModal", () => {
|
|||||||
expect(await screen.findByRole("alert")).toHaveTextContent("stats unavailable");
|
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 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 () => {
|
it("refreshes every 5 seconds while open and stops when closed", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
mockFetchSystemStats.mockResolvedValue(sampleStats);
|
|
||||||
|
|
||||||
const { rerender } = render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
|
const { rerender } = render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,10 @@ const mockCentralListProjects = vi.fn().mockResolvedValue([]);
|
|||||||
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
|
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
|
||||||
const mockCentralClose = vi.fn().mockResolvedValue(undefined);
|
const mockCentralClose = vi.fn().mockResolvedValue(undefined);
|
||||||
const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
|
const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
|
||||||
const { mockPerformUpdateCheck, mockClearUpdateCheckCache } = vi.hoisted(() => ({
|
const { mockPerformUpdateCheck, mockClearUpdateCheckCache, mockExecSync } = vi.hoisted(() => ({
|
||||||
mockPerformUpdateCheck: vi.fn(),
|
mockPerformUpdateCheck: vi.fn(),
|
||||||
mockClearUpdateCheckCache: vi.fn(),
|
mockClearUpdateCheckCache: vi.fn(),
|
||||||
|
mockExecSync: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../update-check.js", async () => {
|
vi.mock("../update-check.js", async () => {
|
||||||
@@ -55,6 +56,15 @@ vi.mock("../update-check.js", async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("node:child_process", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||||
|
mockExecSync.mockImplementation(((...args: Parameters<typeof actual.execSync>) => actual.execSync(...args)) as typeof actual.execSync);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
execSync: mockExecSync,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("@fusion/core", async () => {
|
vi.mock("@fusion/core", async () => {
|
||||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||||
return {
|
return {
|
||||||
@@ -290,6 +300,8 @@ describe("GET /api/system-stats", () => {
|
|||||||
getFusionDir: vi.fn().mockReturnValue("/fake/default"),
|
getFusionDir: vi.fn().mockReturnValue("/fake/default"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mockExecSync.mockReturnValue(`${process.pid}\n111\n222\n` as never);
|
||||||
|
|
||||||
vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
|
vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
|
||||||
vi.spyOn(AgentStore.prototype, "listAgents").mockResolvedValue([
|
vi.spyOn(AgentStore.prototype, "listAgents").mockResolvedValue([
|
||||||
{ id: "agent-1", state: "idle" },
|
{ id: "agent-1", state: "idle" },
|
||||||
@@ -337,6 +349,27 @@ describe("GET /api/system-stats", () => {
|
|||||||
error: 1,
|
error: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(res.body.vitestProcessCount).toBe(2);
|
||||||
|
expect(res.body.vitestLastAutoKillAt).toBeNull();
|
||||||
|
mockExecSync.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes last auto-kill timestamp when available in global settings", async () => {
|
||||||
|
const store = createMockStore({
|
||||||
|
listTasks: vi.fn().mockResolvedValue([]),
|
||||||
|
getFusionDir: vi.fn().mockReturnValue("/fake/default"),
|
||||||
|
getGlobalSettingsStore: vi.fn().mockReturnValue({
|
||||||
|
getSettings: vi.fn().mockResolvedValue({ vitestLastAutoKillAt: "2026-04-27T12:00:00.000Z" }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(AgentStore.prototype, "listAgents").mockResolvedValue([]);
|
||||||
|
|
||||||
|
const res = await GET(buildApp(store), "/api/system-stats");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.vitestLastAutoKillAt).toBe("2026-04-27T12:00:00.000Z");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses project-scoped store when projectId query param is provided", async () => {
|
it("uses project-scoped store when projectId query param is provided", async () => {
|
||||||
@@ -363,6 +396,56 @@ describe("GET /api/system-stats", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("POST /api/kill-vitest", () => {
|
||||||
|
function buildApp(store: TaskStore) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api", createApiRoutes(store));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns killed: 0 when no vitest processes are found", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
mockExecSync.mockReturnValue("" as never);
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(store), "POST", "/api/kill-vitest");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ killed: 0, pids: [] });
|
||||||
|
mockExecSync.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kills all matched vitest pids except the current dashboard process", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
mockExecSync.mockReturnValue(`${process.pid}\n1001\n1002\nnot-a-pid\n` as never);
|
||||||
|
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(store), "POST", "/api/kill-vitest");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(killSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(killSpy).toHaveBeenNthCalledWith(1, 1001, "SIGKILL");
|
||||||
|
expect(killSpy).toHaveBeenNthCalledWith(2, 1002, "SIGKILL");
|
||||||
|
expect(res.body).toEqual({ killed: 2, pids: [1001, 1002] });
|
||||||
|
|
||||||
|
killSpy.mockRestore();
|
||||||
|
mockExecSync.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns killed: 0 when pgrep exits with no matches", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
mockExecSync.mockImplementation(() => {
|
||||||
|
throw new Error("pgrep exited 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(store), "POST", "/api/kill-vitest");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ killed: 0, pids: [] });
|
||||||
|
mockExecSync.mockReset();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("GET /api/plugins/runtimes", () => {
|
describe("GET /api/plugins/runtimes", () => {
|
||||||
function buildApp(pluginLoader?: { getPluginRuntimes?: () => Array<{ pluginId: string; runtime: { metadata: { runtimeId: string; name: string; description?: string; version?: string }; factory: () => unknown } }> }) {
|
function buildApp(pluginLoader?: { getPluginRuntimes?: () => Array<{ pluginId: string; runtime: { metadata: { runtimeId: string; name: string; description?: string; version?: string }; factory: () => unknown } }> }) {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|||||||
@@ -1200,6 +1200,24 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getVitestProcessIds = async (): Promise<number[]> => {
|
||||||
|
const { execSync } = await import("node:child_process");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = execSync("pgrep -f vitest", {
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return output
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => Number.parseInt(line.trim(), 10))
|
||||||
|
.filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/system-stats
|
* GET /api/system-stats
|
||||||
* Returns process/system metrics plus task and agent aggregates.
|
* Returns process/system metrics plus task and agent aggregates.
|
||||||
@@ -1210,6 +1228,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
const mem = process.memoryUsage();
|
const mem = process.memoryUsage();
|
||||||
const heapStats = v8.getHeapStatistics();
|
const heapStats = v8.getHeapStatistics();
|
||||||
const load = os.loadavg();
|
const load = os.loadavg();
|
||||||
|
const vitestProcessIds = await getVitestProcessIds();
|
||||||
|
|
||||||
|
let vitestLastAutoKillAt: string | null = null;
|
||||||
|
const globalSettingsStore = scopedStore.getGlobalSettingsStore?.();
|
||||||
|
if (globalSettingsStore?.getSettings) {
|
||||||
|
const globalSettings = await globalSettingsStore.getSettings();
|
||||||
|
const candidate = (globalSettings as Record<string, unknown>).vitestLastAutoKillAt;
|
||||||
|
if (typeof candidate === "string" && candidate.length > 0) {
|
||||||
|
vitestLastAutoKillAt = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
|
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
|
||||||
const byColumn: Record<string, number> = {
|
const byColumn: Record<string, number> = {
|
||||||
@@ -1259,6 +1288,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
active: tasks.filter((task) => task.column === "in-progress" || task.column === "in-review").length,
|
active: tasks.filter((task) => task.column === "in-progress" || task.column === "in-review").length,
|
||||||
agents: agentCounts,
|
agents: agentCounts,
|
||||||
},
|
},
|
||||||
|
vitestProcessCount: vitestProcessIds.length,
|
||||||
|
vitestLastAutoKillAt,
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
@@ -1268,6 +1299,36 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/kill-vitest
|
||||||
|
* Kill all running vitest processes (excluding this process).
|
||||||
|
*/
|
||||||
|
router.post("/kill-vitest", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const vitestProcessIds = await getVitestProcessIds();
|
||||||
|
const killedPids: number[] = [];
|
||||||
|
|
||||||
|
for (const pid of vitestProcessIds) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL");
|
||||||
|
killedPids.push(pid);
|
||||||
|
} catch {
|
||||||
|
// Process may have exited before kill.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
killed: killedPids.length,
|
||||||
|
pids: killedPids,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
rethrowAsApiError(err, "Failed to kill vitest processes");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Backup Routes ─────────────────────────────────────────────────
|
// ── Backup Routes ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user