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(path: string, gpuIndex?: number): Promise { 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; } async function post( path: string, body?: unknown, gpuIndex?: number, ): Promise { 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; // 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(path: string, gpuIndex?: number): Promise { 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; // 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("/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("/gpus"), gpu: (gpuIndex: number) => get("/gpu", gpuIndex), dashboard: (gpuIndex: number) => get("/dashboard", gpuIndex), curve: (gpuIndex: number) => get("/curve", gpuIndex), ranges: (gpuIndex: number) => get>( "/ranges", gpuIndex, ), voltage: (gpuIndex: number) => get<{ voltage_uv: number; voltage_mv: number }>("/voltage", gpuIndex), monitor: (gpuIndex: number) => get("/monitor", gpuIndex), processes: (gpuIndex: number) => get("/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("/snapshots", gpuIndex), /** Write per-point frequency deltas. deltas: { pointIndex: deltaKhz } */ writeDeltas: (deltas: Record, 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("/limits", gpuIndex), /** Set performance limits */ updateLimits: (updates: Partial, 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("/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), };