Add Proxmox integration

Fifth live integration: VM/LXC status across every online node in the
cluster, with start/stop/restart actions — matching the "dashboard + basic
actions" depth from the plan. Adapter built against Proxmox VE's own
published API tree (pve.proxmox.com/pve-docs/api-viewer/apidoc.js — parsed
its ~4MB ExtJS tree structure directly since it's not plain JSON) plus
community-verified docs for the token auth header format, since the user's
instance isn't reachable from here.

server/src/integrations/proxmox/adapter.ts: GET /nodes for online nodes,
then GET /nodes/{node}/{qemu,lxc} per node in parallel (Promise.all) and
flattened, tagging each guest with its node and type; POST
/nodes/{node}/{qemu,lxc}/{vmid}/status/{start,stop,reboot} for actions (all
confirmed token-auth-eligible via the spec's "allowtoken" flag). Uses
node:https directly (like the cPanel DNS adapter) rather than fetch, since
Proxmox commonly runs a self-signed certificate in homelab setups — added
an "Allow self-signed certificate" checkbox field for that. Auth is
`Authorization: PVEAPIToken=<tokenId>=<tokenSecret>`, a different shape
from the other three integrations' single bearer token, so the field
schema splits it into two fields (a non-secret token ID like
"root@pam!homelab-manager" and a secret token value).

Verified: full build passes. Unreachable from here, so ran a 16-check HTTP
test against the live server: role gating, credential non-leakage,
disabled-integration blocking, invalid-guest-type and non-numeric-vmid
rejection, checkbox-only config correctly still failing required-field
validation, and the wrong_type crash-safety check for this fifth adapter
type — server stays up throughout. Real node/VM data and the actual
start/stop/restart actions still need verification once this app can reach
the user's Proxmox cluster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 00:24:37 +02:00
1 parent 257ec3ec0a
commit bc72b9e152
7 files changed
+454 -1

No files matched your search

+145
View File
@@ -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" ? (
<span className="badge bg-green-lt text-green">Running</span>
) : (
<span className="badge bg-secondary-lt text-secondary">Stopped</span>
);
}
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 <span className="badge bg-secondary-lt text-secondary">Never run</span>;
@@ -117,6 +136,11 @@ export default function Integrations({ user }: { user: CurrentUser }) {
const [loadingTemplates, setLoadingTemplates] = useState(false);
const [runningTemplate, setRunningTemplate] = useState<number | null>(null);
const [proxmoxData, setProxmoxData] = useState<ProxmoxGuestsResponse | null>(null);
const [proxmoxError, setProxmoxError] = useState<string | null>(null);
const [loadingGuests, setLoadingGuests] = useState(false);
const [actingOnGuest, setActingOnGuest] = useState<number | null>(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 (
<>
<div className="d-flex align-items-center mb-3">
@@ -675,6 +729,97 @@ export default function Integrations({ user }: { user: CurrentUser }) {
</table>
</div>
</div>
) : selected?.type === "proxmox" ? (
<div className="card">
<div className="card-header">
<h3 className="card-title">
VMs &amp; Containers
{proxmoxData && (
<span className="text-secondary fw-normal ms-2">
{proxmoxData.summary.running}/{proxmoxData.summary.total} running
</span>
)}
</h3>
<div className="card-actions">
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => loadProxmoxGuests(selected.id)}
disabled={loadingGuests}
>
{loadingGuests ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
{proxmoxError && <div className="alert alert-danger m-3 mb-0">{proxmoxError}</div>}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
<th>Node</th>
<th>Type</th>
<th>Uptime</th>
<th>Status</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{proxmoxData?.guests.map((g) => (
<tr key={`${g.node}:${g.type}:${g.vmid}`}>
<td>
{g.name} <span className="text-secondary">#{g.vmid}</span>
</td>
<td className="text-secondary">{g.node}</td>
<td>
<span className="badge bg-blue-lt">{g.type === "qemu" ? "VM" : "LXC"}</span>
</td>
<td className="text-secondary">{formatUptime(g.uptime)}</td>
<td>{proxmoxStatusBadge(g.status)}</td>
{canEdit && (
<td>
<div className="btn-list flex-nowrap">
{g.status === "running" ? (
<>
<button
className="btn btn-sm"
onClick={() => guestAction(g, "restart")}
disabled={actingOnGuest === g.vmid}
>
Restart
</button>
<button
className="btn btn-sm btn-outline-danger"
onClick={() => guestAction(g, "stop")}
disabled={actingOnGuest === g.vmid}
>
Stop
</button>
</>
) : (
<button
className="btn btn-sm btn-primary"
onClick={() => guestAction(g, "start")}
disabled={actingOnGuest === g.vmid}
>
Start
</button>
)}
</div>
</td>
)}
</tr>
))}
{proxmoxData?.guests.length === 0 && (
<tr>
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
No VMs or containers found across any node.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
) : (
<div className="card">
<div className="card-body text-secondary">