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:
1 parent
b75b9d43e9
commit
c11ea73ead
10 files changed
+1008
-23
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user