diff --git a/server/src/integrations/fieldSchemas.ts b/server/src/integrations/fieldSchemas.ts index 9a29cdf..c9cd9b4 100644 --- a/server/src/integrations/fieldSchemas.ts +++ b/server/src/integrations/fieldSchemas.ts @@ -27,6 +27,12 @@ export const INTEGRATION_FIELDS: Partial=` — see + * https://pve.proxmox.com/wiki/Proxmox_VE_API#API_Tokens + * + * Endpoints verified against Proxmox's own published API tree + * (https://pve.proxmox.com/pve-docs/api-viewer/apidoc.js): GET /nodes, GET + * /nodes/{node}/qemu, GET /nodes/{node}/lxc, and POST + * /nodes/{node}/{qemu,lxc}/{vmid}/status/{start,stop,reboot} (all + * token-auth-eligible per that spec's "allowtoken" flag). + * + * Proxmox commonly runs with a self-signed certificate in homelab setups, so + * (like the cPanel DNS adapter) this uses node:https directly rather than + * fetch, to support an "insecure" opt-out of certificate verification. + */ +import * as https from "node:https"; + +export interface ProxmoxConfig { + url: string; + tokenId: string; + tokenSecret: string; + insecure?: boolean; +} + +export type ProxmoxGuestType = "qemu" | "lxc"; + +export interface ProxmoxGuest { + vmid: number; + name: string; + node: string; + type: ProxmoxGuestType; + status: string; // "running" | "stopped" + cpu: number | null; + maxmem: number | null; + mem: number | null; + uptime: number | null; +} + +export interface ProxmoxAdapter { + ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; + listGuests(): Promise; + startGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise; + stopGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise; + restartGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise; +} + +interface RawResponse { + status: number; + text: () => string; +} + +function request(url: string, insecure: boolean, options: { method?: string; headers?: Record } = {}): Promise { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const req = https.request( + { + hostname: parsed.hostname, + port: parsed.port || 8006, + path: parsed.pathname + parsed.search, + method: options.method || "GET", + headers: options.headers || {}, + rejectUnauthorized: !insecure, + }, + (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => resolve({ status: res.statusCode ?? 0, text: () => body })); + }, + ); + req.on("error", reject); + req.end(); + }); +} + +export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter { + const insecure = config.insecure === true; + + function base() { + return config.url.replace(/\/$/, ""); + } + + function headers() { + return { + Authorization: `PVEAPIToken=${config.tokenId}=${config.tokenSecret}`, + Accept: "application/json", + }; + } + + async function api(method: string, path: string): Promise { + const res = await request(`${base()}/api2/json${path}`, insecure, { method, headers: headers() }); + let data: any = null; + try { + data = res.text() ? JSON.parse(res.text()) : null; + } catch { + // non-JSON error page + } + if (res.status < 200 || res.status >= 300) { + const message = data?.errors ? JSON.stringify(data.errors) : data?.message; + throw new Error(message || `Proxmox API error: HTTP ${res.status}`); + } + return data?.data; + } + + async function listNodes(): Promise { + const data = await api("GET", "/nodes"); + return (Array.isArray(data) ? data : []) + .filter((n: any) => n.status === "online") + .map((n: any) => n.node); + } + + async function listGuestsForNode(node: string, type: ProxmoxGuestType): Promise { + const data = await api("GET", `/nodes/${node}/${type}`); + return (Array.isArray(data) ? data : []).map((g: any) => ({ + vmid: g.vmid, + name: g.name, + node, + type, + status: g.status, + cpu: g.cpu ?? null, + maxmem: g.maxmem ?? null, + mem: g.mem ?? null, + uptime: g.uptime ?? null, + })); + } + + async function listGuests(): Promise { + const nodes = await listNodes(); + const perNode = await Promise.all( + nodes.map(async (node) => { + try { + const [vms, containers] = await Promise.all([ + listGuestsForNode(node, "qemu"), + listGuestsForNode(node, "lxc"), + ]); + return [...vms, ...containers]; + } catch { + // one unreachable/offline node shouldn't take down the whole dashboard view + return []; + } + }), + ); + return perNode.flat(); + } + + async function statusAction(node: string, type: ProxmoxGuestType, vmid: number, action: string): Promise { + await api("POST", `/nodes/${node}/${type}/${vmid}/status/${action}`); + } + + const startGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "start"); + const stopGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "stop"); + const restartGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "reboot"); + + async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { + const start = Date.now(); + try { + await api("GET", "/nodes"); + return { ok: true, latencyMs: Date.now() - start }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + return { ping, listGuests, startGuest, stopGuest, restartGuest }; +} diff --git a/server/src/integrations/registry.ts b/server/src/integrations/registry.ts index d13b251..52b2f0a 100644 --- a/server/src/integrations/registry.ts +++ b/server/src/integrations/registry.ts @@ -4,6 +4,7 @@ import { createTailscaleAdapter } from "./tailscale/adapter.js"; import { createGiteaAdapter } from "./gitea/adapter.js"; import { createDockhandAdapter } from "./dockhand/adapter.js"; import { createSemaphoreAdapter } from "./semaphore/adapter.js"; +import { createProxmoxAdapter } from "./proxmox/adapter.js"; export interface PingableAdapter { ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; @@ -26,6 +27,8 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat return createDockhandAdapter(config as any); case "semaphore": return createSemaphoreAdapter(config as any); + case "proxmox": + return createProxmoxAdapter(config as any); default: throw new Error(`Integration type "${type}" is not implemented yet`); } diff --git a/server/src/routes/integrations.ts b/server/src/routes/integrations.ts index 0455267..3476013 100644 --- a/server/src/routes/integrations.ts +++ b/server/src/routes/integrations.ts @@ -18,6 +18,7 @@ import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js"; import { createGiteaAdapter } from "../integrations/gitea/adapter.js"; import { createDockhandAdapter } from "../integrations/dockhand/adapter.js"; import { createSemaphoreAdapter } from "../integrations/semaphore/adapter.js"; +import { createProxmoxAdapter } from "../integrations/proxmox/adapter.js"; import { asyncHandler } from "../utils/asyncHandler.js"; export const integrationsRouter = Router(); @@ -538,3 +539,78 @@ integrationsRouter.post( } }), ); + +// ─── Proxmox ───────────────────────────────────────────────────────────────── + +async function requireProxmoxAdapter(req: Request, res: Response) { + const id = Number(req.params.id); + const loaded = await loadIntegrationConfig(id); + if (!loaded) { + res.status(404).json({ error: "not_found" }); + return null; + } + if (loaded.integration.type !== "proxmox") { + res.status(400).json({ error: "wrong_type" }); + return null; + } + if (!loaded.integration.enabled) { + res.status(400).json({ error: "integration_disabled" }); + return null; + } + return { integration: loaded.integration, adapter: createProxmoxAdapter(loaded.config as any) }; +} + +integrationsRouter.get("/:id/proxmox/guests", asyncHandler(async (req, res) => { + const found = await requireProxmoxAdapter(req, res); + if (!found) return; + + try { + const guests = await found.adapter.listGuests(); + res.json({ + guests, + summary: { + total: guests.length, + running: guests.filter((g) => g.status === "running").length, + }, + }); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } +})); + +const proxmoxActions = ["start", "stop", "restart"] as const; + +for (const action of proxmoxActions) { + integrationsRouter.post( + `/:id/proxmox/nodes/:node/:type/:vmid/${action}`, + requireRole("operator"), + asyncHandler(async (req, res) => { + const found = await requireProxmoxAdapter(req, res); + if (!found) return; + + const vmid = Number(req.params.vmid); + if (!Number.isInteger(vmid)) { + return res.status(400).json({ error: "invalid_vmid" }); + } + const type = req.params.type; + if (type !== "qemu" && type !== "lxc") { + return res.status(400).json({ error: "invalid_type" }); + } + + try { + await found.adapter[`${action}Guest`](req.params.node, type, vmid); + await recordAudit({ + actor: req.currentUser!, + category: "integration", + action: `${action}_guest`, + targetType: "proxmox_guest", + targetId: vmid, + detail: { integrationId: found.integration.id, node: req.params.node, guestType: type }, + }); + res.status(204).end(); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } + }), + ); +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5af7955..6bfcd25 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -271,6 +271,25 @@ export interface SemaphoreTemplatesResponse { summary: { total: number; failing: number }; } +export type ProxmoxGuestType = "qemu" | "lxc"; + +export interface ProxmoxGuest { + vmid: number; + name: string; + node: string; + type: ProxmoxGuestType; + status: string; + cpu: number | null; + maxmem: number | null; + mem: number | null; + uptime: number | null; +} + +export interface ProxmoxGuestsResponse { + guests: ProxmoxGuest[]; + summary: { total: number; running: number }; +} + export class UnauthorizedError extends Error {} export class ForbiddenError extends Error {} @@ -477,5 +496,14 @@ export const api = { { method: "POST" }, ), }, + proxmox: { + guests: (integrationId: number) => request(`/api/integrations/${integrationId}/proxmox/guests`), + start: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) => + request(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/start`, { method: "POST" }), + stop: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) => + request(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/stop`, { method: "POST" }), + restart: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) => + request(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/restart`, { method: "POST" }), + }, }, }; diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index f2ee61c..9fcd663 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -26,12 +26,18 @@ interface SemaphoreSummary { failing: number; } +interface ProxmoxSummary { + running: number; + total: number; +} + export default function Dashboard({ user }: { user: CurrentUser }) { const [integrations, setIntegrations] = useState(null); const [tailscaleSummary, setTailscaleSummary] = useState(null); const [giteaSummary, setGiteaSummary] = useState(null); const [dockhandSummary, setDockhandSummary] = useState(null); const [semaphoreSummary, setSemaphoreSummary] = useState(null); + const [proxmoxSummary, setProxmoxSummary] = useState(null); useEffect(() => { api.integrations.list().then((res) => setIntegrations(res.integrations)); @@ -90,6 +96,18 @@ export default function Dashboard({ user }: { user: CurrentUser }) { .catch(() => setSemaphoreSummary(null)); }, [integrations]); + useEffect(() => { + const proxmox = integrations?.find((i) => i.type === "proxmox" && i.enabled); + if (!proxmox) { + setProxmoxSummary(null); + return; + } + api.integrations.proxmox + .guests(proxmox.id) + .then((res) => setProxmoxSummary({ running: res.summary.running, total: res.summary.total })) + .catch(() => setProxmoxSummary(null)); + }, [integrations]); + return ( <>
@@ -109,7 +127,8 @@ export default function Dashboard({ user }: { user: CurrentUser }) { const isLiveGitea = type === "gitea" && integration && giteaSummary; const isLiveDockhand = type === "dockhand" && integration && dockhandSummary; const isLiveSemaphore = type === "semaphore" && integration && semaphoreSummary; - const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore; + const isLiveProxmox = type === "proxmox" && integration && proxmoxSummary; + const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox; return (
@@ -160,6 +179,13 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
{semaphoreSummary!.failing} last failed
)}
+ ) : isLiveProxmox ? ( +
+
+ {proxmoxSummary!.running}/{proxmoxSummary!.total} +
+
VMs/containers running
+
) : (
{integration ? ( diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index d93b5f9..14075d6 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -7,6 +7,8 @@ import { type GiteaRepo, type IntegrationSummary, type IntegrationType, + type ProxmoxGuest, + type ProxmoxGuestsResponse, type SemaphoreTemplate, type SemaphoreTemplatesResponse, type TailscaleDevice, @@ -14,6 +16,23 @@ import { } from "../api/client"; import IntegrationForm from "../components/IntegrationForm"; +function proxmoxStatusBadge(status: string) { + return status === "running" ? ( + Running + ) : ( + Stopped + ); +} + +function formatUptime(seconds: number | null): string { + if (!seconds) return "—"; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + if (days > 0) return `${days}d ${hours}h`; + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; +} + function semaphoreStatusBadge(template: SemaphoreTemplate) { const status = template.lastTask?.status; if (!status) return Never run; @@ -117,6 +136,11 @@ export default function Integrations({ user }: { user: CurrentUser }) { const [loadingTemplates, setLoadingTemplates] = useState(false); const [runningTemplate, setRunningTemplate] = useState(null); + const [proxmoxData, setProxmoxData] = useState(null); + const [proxmoxError, setProxmoxError] = useState(null); + const [loadingGuests, setLoadingGuests] = useState(false); + const [actingOnGuest, setActingOnGuest] = useState(null); + function loadIntegrations() { api.integrations .list() @@ -182,6 +206,11 @@ export default function Integrations({ user }: { user: CurrentUser }) { } else { setSemaphoreData(null); } + if (selected?.type === "proxmox" && selected.enabled && !managing) { + loadProxmoxGuests(selected.id); + } else { + setProxmoxData(null); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedId, managing, integrations]); @@ -195,6 +224,16 @@ export default function Integrations({ user }: { user: CurrentUser }) { .finally(() => setLoadingTemplates(false)); } + function loadProxmoxGuests(id: number) { + setLoadingGuests(true); + setProxmoxError(null); + api.integrations.proxmox + .guests(id) + .then((res) => setProxmoxData(res)) + .catch((err) => setProxmoxError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoadingGuests(false)); + } + async function toggleEnabled(i: IntegrationSummary) { try { await api.integrations.update(i.id, { enabled: !i.enabled }); @@ -280,6 +319,21 @@ export default function Integrations({ user }: { user: CurrentUser }) { } } + async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart") { + if (!selectedId) return; + if (action === "stop" && !confirm(`Stop ${g.type === "qemu" ? "VM" : "container"} "${g.name}"?`)) return; + setActingOnGuest(g.vmid); + setProxmoxError(null); + try { + await api.integrations.proxmox[action](selectedId, g.node, g.type, g.vmid); + loadProxmoxGuests(selectedId); + } catch (err) { + setProxmoxError(err instanceof Error ? err.message : String(err)); + } finally { + setActingOnGuest(null); + } + } + return ( <>
@@ -675,6 +729,97 @@ export default function Integrations({ user }: { user: CurrentUser }) {
+ ) : selected?.type === "proxmox" ? ( +
+
+

+ VMs & Containers + {proxmoxData && ( + + {proxmoxData.summary.running}/{proxmoxData.summary.total} running + + )} +

+
+ +
+
+ {proxmoxError &&
{proxmoxError}
} +
+ + + + + + + + + {canEdit && } + + + + {proxmoxData?.guests.map((g) => ( + + + + + + + {canEdit && ( + + )} + + ))} + {proxmoxData?.guests.length === 0 && ( + + + + )} + +
NameNodeTypeUptimeStatusActions
+ {g.name} #{g.vmid} + {g.node} + {g.type === "qemu" ? "VM" : "LXC"} + {formatUptime(g.uptime)}{proxmoxStatusBadge(g.status)} +
+ {g.status === "running" ? ( + <> + + + + ) : ( + + )} +
+
+ No VMs or containers found across any node. +
+
+
) : (