Files
nvcurve/frontend/src/api/client.ts
T
ARIA c11ea73ead 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
2026-10-03 20:25:33 +02:00

189 lines
6.4 KiB
TypeScript

import type {
CurveState,
GpuInfo,
MonitoringSample,
SnapshotInfo,
LimitsState,
ProfileData,
FanState,
FanPoint,
DashboardInfo,
ProcessListResponse,
} from "../types.js";
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
// Global handler invoked when any API call returns 401 (not logged in, or the
// 24-hour session expired). The app uses it to re-show the login screen.
let unauthorizedHandler: (() => void) | null = null;
export function onUnauthorized(handler: (() => void) | null): void {
unauthorizedHandler = handler;
}
function handleUnauthorized(res: Response): void {
if (res.status === 401 && unauthorizedHandler) {
unauthorizedHandler();
}
}
async function get<T>(path: string, gpuIndex?: number): Promise<T> {
const url =
gpuIndex !== undefined
? `/api${path}?gpu_index=${gpuIndex}`
: `/api${path}`;
const res = await fetch(url);
if (!res.ok) {
handleUnauthorized(res);
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, `GET ${url}: ${res.status} — ${text}`);
}
return res.json() as Promise<T>;
}
async function post<T = void>(
path: string,
body?: unknown,
gpuIndex?: number,
): Promise<T> {
const url =
gpuIndex !== undefined
? `/api${path}?gpu_index=${gpuIndex}`
: `/api${path}`;
const res = await fetch(url, {
method: "POST",
headers: body != null ? { "Content-Type": "application/json" } : {},
body: body != null ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
handleUnauthorized(res);
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, `POST ${url}: ${res.status} — ${text}`);
}
// Some endpoints return no body (204)
const ct = res.headers.get("content-type") ?? "";
if (ct.includes("application/json")) return res.json() as Promise<T>;
// SAFETY: non-JSON responses (e.g. 204) carry no body; callers of these
// endpoints use T = void and ignore the result, so undefined is a valid T.
return undefined as unknown as T;
}
async function del<T = void>(path: string, gpuIndex?: number): Promise<T> {
const url =
gpuIndex !== undefined
? `/api${path}?gpu_index=${gpuIndex}`
: `/api${path}`;
const res = await fetch(url, { method: "DELETE" });
if (!res.ok) {
handleUnauthorized(res);
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, `DELETE ${url}: ${res.status} — ${text}`);
}
const ct = res.headers.get("content-type") ?? "";
if (ct.includes("application/json")) return res.json() as Promise<T>;
// SAFETY: non-JSON responses (e.g. 204) carry no body; callers of these
// endpoints use T = void and ignore the result, so undefined is a valid T.
return undefined as unknown as T;
}
export interface AuthStatus {
auth_required: boolean;
authenticated: boolean;
username: string | null;
expires_at: number | null;
}
export const api = {
/** Auth */
authStatus: () => get<AuthStatus>("/auth/status"),
login: (username: string, password: string) =>
post<{ ok: boolean; username: string; expires_at: number; token: string }>(
"/auth/login",
{ username, password },
),
logout: () => post("/auth/logout"),
gpus: () => get<GpuInfo[]>("/gpus"),
gpu: (gpuIndex: number) => get<GpuInfo>("/gpu", gpuIndex),
dashboard: (gpuIndex: number) => get<DashboardInfo>("/dashboard", gpuIndex),
curve: (gpuIndex: number) => get<CurveState>("/curve", gpuIndex),
ranges: (gpuIndex: number) =>
get<Record<string, { min_khz: number; max_khz: number }>>(
"/ranges",
gpuIndex,
),
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 } */
writeDeltas: (deltas: Record<number, number>, gpuIndex: number) =>
post<{ ok: boolean; freq_warnings?: string[] }>(
"/curve/write",
{ deltas },
gpuIndex,
),
/** Reset all frequency deltas to zero. */
resetCurve: (gpuIndex: number) => post("/curve/reset", undefined, gpuIndex),
/** Get performance limits mapping */
limits: (gpuIndex: number) => get<LimitsState>("/limits", gpuIndex),
/** Set performance limits */
updateLimits: (updates: Partial<LimitsState>, gpuIndex: number) =>
post("/limits", updates, gpuIndex),
/** Reset power limit and memory offset to hardware defaults */
resetLimits: (gpuIndex: number) => post("/limits/reset", undefined, gpuIndex),
/** Profile Management */
profiles: (gpuIndex: number) =>
get<{
profiles: ProfileData[];
active: string | null;
auto_load: string | null;
}>("/profiles", gpuIndex),
saveProfile: (name: string, gpuIndex: number) =>
post<{ ok: boolean; filepath: string }>("/profiles", { name }, gpuIndex),
applyProfile: (name: string, gpuIndex: number) =>
post(`/profiles/${encodeURIComponent(name)}/apply`, undefined, gpuIndex),
deleteProfile: (name: string) => del(`/profiles/${encodeURIComponent(name)}`),
renameProfile: (oldName: string, newName: string) =>
post(`/profiles/${encodeURIComponent(oldName)}/rename`, {
new_name: newName,
}),
/** Server config */
setAutoLoadProfile: (name: string | null, gpuIndex: number) =>
post<{ ok: boolean; auto_load_profile: string | null }>("/config", {
auto_load_profile: name,
gpu_index: gpuIndex,
}),
/** Fan control */
fans: (gpuIndex: number) => get<FanState>("/fans", gpuIndex),
updateFans: (curve: FanPoint[], gpuIndex: number, fans?: number[] | null) =>
post("/fans", { curve, fans: fans ?? null }, gpuIndex),
resetFans: (gpuIndex: number) => post("/fans/reset", undefined, gpuIndex),
setFanSpeed: (fanPct: number, gpuIndex: number, fan?: number | null) =>
post("/fans/speed", { fan_pct: fanPct, fan: fan ?? null }, gpuIndex),
};