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:
Fusion
2026-04-27 05:50:40 -07:00
committed by gsxdsm
parent fce1755d2b
commit ec092825ac
8 changed files with 506 additions and 9 deletions

View File

@@ -4896,12 +4896,25 @@ export interface TaskStatsSnapshot {
export interface SystemStatsResponse {
systemStats: SystemStatsSnapshot;
taskStats: TaskStatsSnapshot;
vitestProcessCount?: number;
vitestLastAutoKillAt?: string | null;
}
export interface KillVitestResponse {
killed: number;
pids: number[];
}
export function fetchSystemStats(projectId?: string): Promise<SystemStatsResponse> {
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 */
export function fetchActivityFeed(options?: FeedOptions): Promise<ActivityFeedEntry[]> {
const params = new URLSearchParams();

View File

@@ -114,6 +114,70 @@
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);
@@ -129,4 +193,24 @@
margin-left: 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;
}
}

View File

@@ -1,6 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Monitor, RefreshCw, X } from "lucide-react";
import { fetchSystemStats, type SystemStatsResponse } from "../api";
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 {
@@ -53,6 +60,13 @@ function severityClassName(severity: Severity): string {
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:
* 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 [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 loadStats = useCallback(async () => {
const loadStats = useCallback(async (options?: { preserveKillResult?: boolean }) => {
setLoading(true);
try {
const response = await fetchSystemStats(projectId);
setStats(response);
setError(null);
if (!options?.preserveKillResult) {
setKillResult(null);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load system stats");
} finally {
@@ -89,6 +112,23 @@ export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModa
};
}, [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(() => {
if (!isOpen) return;
const onKeydown = (event: KeyboardEvent) => {
@@ -112,6 +152,48 @@ export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModa
];
}, [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;
const system = stats?.systemStats;
@@ -120,6 +202,13 @@ export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModa
const usedSystemClassName = system
? 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 (
<div
@@ -263,6 +352,82 @@ export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModa
</div>
</dl>
</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>
)}

View File

@@ -1,17 +1,25 @@
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";
vi.mock("lucide-react", () => ({
Monitor: () => <span data-testid="icon-monitor" />,
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" />,
}));
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 = {
@@ -49,11 +57,20 @@ const sampleStats = {
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(() => {
@@ -69,12 +86,11 @@ describe("SystemStatsModal", () => {
});
it("renders fetched metrics across all sections", async () => {
mockFetchSystemStats.mockResolvedValue(sampleStats);
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} projectId="proj-1" />);
await waitFor(() => {
expect(mockFetchSystemStats).toHaveBeenCalledWith("proj-1");
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
});
expect(screen.getByText("System Stats")).toBeDefined();
@@ -83,12 +99,15 @@ describe("SystemStatsModal", () => {
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();
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);
@@ -102,9 +121,76 @@ describe("SystemStatsModal", () => {
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 () => {
vi.useFakeTimers();
mockFetchSystemStats.mockResolvedValue(sampleStats);
const { rerender } = render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);