feat: GPU process list with per-process VRAM/utilization and kill

Add a GPU process table to the Performance tab showing which processes
use the selected GPU, with per-process VRAM, GPU utilization, CPU, host
memory, and command. Includes a kill endpoint (SIGTERM/SIGKILL, with
optional parent kill) behind a confirm dialog.

- nvcurve/hal/processes.py: NVML + /proc process enumeration
- server.py: GET /api/processes, POST /api/processes/kill (refuses to
  signal init/kthreadd)
- frontend: ProcessList component, API client, types
- tests: test_processes.py
- docs: API reference for the new endpoints
This commit is contained in:
ARIA committed 2026-10-03 20:25:33 +02:00
1 parent b75b9d43e9
commit c11ea73ead
10 files changed
+1008 -23

No files matched your search

+13 -9
View File
@@ -9,6 +9,7 @@ import { CurveEditor } from "./components/CurveEditor/CurveEditor.js";
import { PointTable } from "./components/PointTable/PointTable.js";
import { PerformancePanel } from "./components/Limits/PerformancePanel.js";
import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor.js";
import { ProcessList } from "./components/Monitor/ProcessList.js";
import { FanMonitor } from "./components/Monitor/FanMonitor.js";
import { FanCurveEditor } from "./components/Fans/FanCurveEditor.js";
import { WireViewPanel } from "./components/WireView/WireViewPanel.js";
@@ -290,16 +291,19 @@ function MainApp({
)}
</>
) : activeTab === "performance" ? (
<div className="flex gap-4 items-start w-full">
<div className="flex-1 min-w-0">
<PerformancePanel />
</div>
<div className="w-80 shrink-0 flex flex-col">
<PerformanceMonitor
monitor={monitor}
history={monitorHistory}
/>
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 items-start w-full">
<div className="flex-1 min-w-0">
<PerformancePanel />
</div>
<div className="w-80 shrink-0 flex flex-col">
<PerformanceMonitor
monitor={monitor}
history={monitorHistory}
/>
</div>
</div>
<ProcessList />
</div>
) : activeTab === "wireview" ? (
<WireViewPanel
+12
View File
@@ -8,6 +8,7 @@ import type {
FanState,
FanPoint,
DashboardInfo,
ProcessListResponse,
} from "../types.js";
export class ApiError extends Error {
@@ -119,6 +120,17 @@ export const api = {
voltage: (gpuIndex: number) =>
get<{ voltage_uv: number; voltage_mv: number }>("/voltage", gpuIndex),
monitor: (gpuIndex: number) => get<MonitoringSample>("/monitor", gpuIndex),
processes: (gpuIndex: number) =>
get<ProcessListResponse>("/processes", gpuIndex),
killProcess: (
pid: number,
signal: "TERM" | "KILL" = "TERM",
parent = false,
) =>
post<{ ok: boolean; pid: number; signal: string; parent: boolean }>(
"/processes/kill",
{ pid, signal, parent },
),
snapshots: (gpuIndex: number) => get<SnapshotInfo[]>("/snapshots", gpuIndex),
/** Write per-point frequency deltas. deltas: { pointIndex: deltaKhz } */
@@ -0,0 +1,254 @@
import { useEffect, useRef, useState } from "react";
import { Loader, X } from "lucide-react";
import { api } from "../../api/client.js";
import { useCurveStore } from "../../store/curveStore.js";
import type { GpuProcess } from "../../types.js";
import { fmt } from "../../utils/units.js";
import { toast } from "sonner";
import { ConfirmDialog } from "../common/ConfirmDialog.js";
const POLL_MS = 3000;
const TYPE_STYLES: Record<GpuProcess["type"], { label: string; cls: string }> =
{
C: { label: "C", cls: "bg-cyan-500/15 text-cyan-400 border-cyan-500/30" },
G: {
label: "G",
cls: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
},
"C+G": {
label: "C+G",
cls: "bg-violet-500/15 text-violet-400 border-violet-500/30",
},
};
const TH =
"px-3 py-1.5 font-semibold text-left text-[10px] uppercase tracking-wider text-zinc-500 whitespace-nowrap";
const TD = "px-3 py-1.5 whitespace-nowrap";
export function ProcessList() {
const { selectedGpuIndex } = useCurveStore();
const [processes, setProcesses] = useState<GpuProcess[] | null>(null);
const [memTotal, setMemTotal] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [killTarget, setKillTarget] = useState<GpuProcess | null>(null);
const [pendingAction, setPendingAction] = useState<
"TERM" | "KILL" | "KILL_PARENT" | null
>(null);
// Ref so handleKill can trigger an immediate refresh after a kill.
const refreshRef = useRef<() => void>(() => {});
useEffect(() => {
let cancelled = false;
async function fetchOnce() {
try {
const data = await api.processes(selectedGpuIndex);
if (cancelled) return;
setProcesses(data.processes);
setMemTotal(data.mem_total_bytes);
setError(null);
} catch (e) {
if (!cancelled)
setError(e instanceof Error ? e.message : String(e));
}
}
refreshRef.current = () => {
void fetchOnce();
};
fetchOnce();
const timer = setInterval(fetchOnce, POLL_MS);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [selectedGpuIndex]);
async function handleKill(action: "TERM" | "KILL" | "KILL_PARENT") {
if (!killTarget || pendingAction) return;
const pid = killTarget.pid;
setPendingAction(action);
try {
const signal = action === "TERM" ? "TERM" : "KILL";
const parent = action === "KILL_PARENT";
const res = await api.killProcess(pid, signal, parent);
toast.success(
parent
? `Sent SIGKILL to parent process ${res.pid} of ${pid}`
: `Sent SIG${signal} to process ${pid}`,
);
setKillTarget(null);
refreshRef.current();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
setPendingAction(null);
}
}
return (
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
{/* ── Header ─────────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
GPU Processes
</span>
{processes && processes.length > 0 && (
<span className="text-xs text-zinc-600">{processes.length}</span>
)}
<div className="ml-auto flex items-center gap-2">
{memTotal != null && (
<span className="text-xs text-zinc-600 font-mono">
VRAM total {fmt.bytes(memTotal)}
</span>
)}
</div>
</div>
{/* ── Error banner ────────────────────────────────────────────────── */}
{error && (
<div className="px-3 py-1.5 bg-red-900/40 border-b border-red-700 text-red-300 text-xs flex items-center justify-between">
<span>⚠ {error}</span>
<button
onClick={() => setError(null)}
className="ml-2 text-red-400 hover:text-red-200"
>
✕
</button>
</div>
)}
{/* ── Table ───────────────────────────────────────────────────────── */}
{processes === null && !error ? (
<div className="p-8 flex items-center justify-center gap-2 text-zinc-500 text-xs">
<Loader size={14} className="animate-spin" />
Loading processes…
</div>
) : processes && processes.length === 0 && !error ? (
<div className="p-8 text-center text-zinc-600 text-xs">
{memTotal === null
? "GPU process data unavailable (NVML not initialized)."
: "No processes using this GPU."}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-zinc-800">
<th className={TH}>PID</th>
<th className={TH}>User</th>
<th className={TH}>Dev</th>
<th className={TH}>Type</th>
<th
className={`${TH} text-right`}
title="NVML per-process utilization — smoothed over the driver's sample window, so it lags instantaneous usage (accurate when steady)"
>
GPU
</th>
<th className={`${TH} text-right`}>VRAM</th>
<th className={`${TH} text-right`}>VRAM %</th>
<th className={`${TH} text-right`}>CPU</th>
<th className={`${TH} text-right`}>MEM</th>
<th className={TH}>Command</th>
<th className={`${TH} text-right`}>Kill</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60 font-mono">
{processes?.map((p) => (
<tr key={p.pid} className="hover:bg-zinc-800/40">
<td className={TD}>{p.pid}</td>
<td className={TD}>{p.user}</td>
<td className={TD}>{p.dev}</td>
<td className={TD}>
<span
className={`inline-block px-1.5 py-0.5 rounded border text-[10px] font-semibold ${TYPE_STYLES[p.type].cls}`}
title={
p.type === "C"
? "Compute"
: p.type === "G"
? "Graphics"
: "Compute + Graphics"
}
>
{TYPE_STYLES[p.type].label}
</span>
</td>
<td className={`${TD} text-right`}>
{fmt.pct(p.gpu_util_pct ?? 0)}
</td>
<td className={`${TD} text-right`}>
{fmt.bytes(p.vram_bytes)}
</td>
<td className={`${TD} text-right`}>
{fmt.pct(p.vram_pct, 1)}
</td>
<td className={`${TD} text-right`}>
{fmt.pct(p.cpu_pct, 1)}
</td>
<td className={`${TD} text-right`}>
{fmt.bytes(p.mem_host_bytes)}
</td>
<td className="px-3 py-1.5 max-w-[320px] min-w-[160px]">
<span
className="block truncate text-zinc-300"
title={p.command}
>
{p.command}
</span>
</td>
<td className={`${TD} text-right`}>
<button
onClick={() => setKillTarget(p)}
className="inline-flex items-center gap-1 px-2 py-1 rounded bg-zinc-800 hover:bg-red-900 text-zinc-400 hover:text-red-300 text-xs transition-colors"
title={`Kill process ${p.pid}`}
>
<X size={11} />
Kill
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ── Kill confirmation ───────────────────────────────────────────── */}
{killTarget && (
<ConfirmDialog
message={`Kill process ${killTarget.pid}?`}
detail={`${killTarget.command || "(unknown command)"} — owned by ${killTarget.user}, using ${fmt.bytes(
killTarget.vram_bytes,
)} VRAM. SIGTERM asks the process to exit; SIGKILL kills it immediately; SIGKILL Parent kills the parent process, which may take down the whole process family.`}
onCancel={() => setKillTarget(null)}
actions={[
{
label: pendingAction === "TERM" ? "Sending…" : "SIGTERM",
onClick: () => handleKill("TERM"),
disabled: pendingAction != null,
className:
"px-3 py-1.5 rounded text-xs font-semibold transition-colors bg-zinc-800 hover:bg-zinc-700 text-zinc-200",
},
{
label: pendingAction === "KILL" ? "Sending…" : "SIGKILL",
onClick: () => handleKill("KILL"),
disabled: pendingAction != null,
className:
"px-3 py-1.5 rounded text-xs font-semibold transition-colors bg-red-600 hover:bg-red-500 text-white",
},
{
label:
pendingAction === "KILL_PARENT" ? "Sending…" : "SIGKILL Parent",
onClick: () => handleKill("KILL_PARENT"),
disabled: pendingAction != null,
className:
"px-3 py-1.5 rounded text-xs font-semibold transition-colors bg-red-900 hover:bg-red-800 text-red-100",
},
]}
/>
)}
</div>
);
}
@@ -1,10 +1,20 @@
export interface DialogAction {
label: string;
onClick: () => void;
className?: string;
disabled?: boolean;
}
interface Props {
message: string;
detail?: string;
confirmLabel?: string;
isDestructive?: boolean;
onConfirm: () => void;
/** Required unless `actions` is provided (which replaces the single confirm button). */
onConfirm?: () => void;
onCancel: () => void;
/** When provided, rendered after Cancel instead of the single confirm button. */
actions?: DialogAction[];
}
export function ConfirmDialog({
@@ -14,6 +24,7 @@ export function ConfirmDialog({
isDestructive = false,
onConfirm,
onCancel,
actions,
}: Props) {
return (
<div
@@ -33,17 +44,34 @@ export function ConfirmDialog({
>
Cancel
</button>
<button
onClick={onConfirm}
className={[
"px-3 py-1.5 rounded text-xs font-semibold transition-colors",
isDestructive
? "bg-red-600 hover:bg-red-500 text-white"
: "bg-emerald-600 hover:bg-emerald-500 text-white",
].join(" ")}
>
{confirmLabel}
</button>
{actions ? (
actions.map((a) => (
<button
key={a.label}
onClick={a.onClick}
disabled={a.disabled}
className={[
a.className ??
"px-3 py-1.5 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-semibold transition-colors",
a.disabled ? "opacity-40 cursor-not-allowed" : "",
].join(" ")}
>
{a.label}
</button>
))
) : (
<button
onClick={() => onConfirm?.()}
className={[
"px-3 py-1.5 rounded text-xs font-semibold transition-colors",
isDestructive
? "bg-red-600 hover:bg-red-500 text-white"
: "bg-emerald-600 hover:bg-emerald-500 text-white",
].join(" ")}
>
{confirmLabel}
</button>
)}
</div>
</div>
</div>
+20
View File
@@ -85,6 +85,26 @@ export interface GpuInfo {
vram_gib: number | null;
}
export interface GpuProcess {
pid: number;
user: string;
dev: number;
type: "C" | "G" | "C+G";
// GPU utilization, only reported for processes active in the last ~1 s.
gpu_util_pct: number | null;
mem_util_pct: number | null;
vram_bytes: number | null;
vram_pct: number | null;
cpu_pct: number | null;
mem_host_bytes: number | null;
command: string;
}
export interface ProcessListResponse {
processes: GpuProcess[];
mem_total_bytes: number | null;
}
export interface SnapshotInfo {
filepath: string;
timestamp: string;