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:
1 parent
257ec3ec0a
commit
bc72b9e152
7 files changed
+454
-1
No files matched your search
@@ -27,6 +27,12 @@ export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationFiel
|
||||
{ key: "url", label: "Semaphore URL", secret: false, placeholder: "https://semaphore.example.lan" },
|
||||
{ key: "token", label: "API token", secret: true, type: "password" },
|
||||
],
|
||||
proxmox: [
|
||||
{ key: "url", label: "Proxmox URL", secret: false, placeholder: "https://pve.example.lan:8006" },
|
||||
{ key: "tokenId", label: "API token ID", secret: false, placeholder: "root@pam!homelab-manager" },
|
||||
{ key: "tokenSecret", label: "API token secret", secret: true, type: "password" },
|
||||
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
|
||||
],
|
||||
};
|
||||
|
||||
/** Fixed base URL per integration type, stored on the row for display/reference. */
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Proxmox VE adapter — uses the Proxmox VE REST API (api2/json).
|
||||
* Requires config: url, tokenId, tokenSecret; optional: insecure
|
||||
*
|
||||
* Auth: `Authorization: PVEAPIToken=<tokenId>=<tokenSecret>` — 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<ProxmoxGuest[]>;
|
||||
startGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
|
||||
stopGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
|
||||
restartGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
|
||||
}
|
||||
|
||||
interface RawResponse {
|
||||
status: number;
|
||||
text: () => string;
|
||||
}
|
||||
|
||||
function request(url: string, insecure: boolean, options: { method?: string; headers?: Record<string, string> } = {}): Promise<RawResponse> {
|
||||
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<any> {
|
||||
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<string[]> {
|
||||
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<ProxmoxGuest[]> {
|
||||
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<ProxmoxGuest[]> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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<ProxmoxGuestsResponse>(`/api/integrations/${integrationId}/proxmox/guests`),
|
||||
start: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
|
||||
request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/start`, { method: "POST" }),
|
||||
stop: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
|
||||
request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/stop`, { method: "POST" }),
|
||||
restart: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
|
||||
request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/restart`, { method: "POST" }),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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<IntegrationSummary[] | null>(null);
|
||||
const [tailscaleSummary, setTailscaleSummary] = useState<TailscaleDevicesResponse["summary"] | null>(null);
|
||||
const [giteaSummary, setGiteaSummary] = useState<GiteaSummary | null>(null);
|
||||
const [dockhandSummary, setDockhandSummary] = useState<DockhandSummary | null>(null);
|
||||
const [semaphoreSummary, setSemaphoreSummary] = useState<SemaphoreSummary | null>(null);
|
||||
const [proxmoxSummary, setProxmoxSummary] = useState<ProxmoxSummary | null>(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 (
|
||||
<>
|
||||
<div className="row row-cards mb-3">
|
||||
@@ -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 (
|
||||
<div className="col-sm-6 col-lg-4" key={type}>
|
||||
@@ -160,6 +179,13 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
<div className="text-red small mt-1">{semaphoreSummary!.failing} last failed</div>
|
||||
)}
|
||||
</div>
|
||||
) : isLiveProxmox ? (
|
||||
<div className="mt-2">
|
||||
<div className="h3 mb-0">
|
||||
{proxmoxSummary!.running}/{proxmoxSummary!.total}
|
||||
</div>
|
||||
<div className="text-secondary">VMs/containers running</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-secondary mt-2">
|
||||
{integration ? (
|
||||
|
||||
@@ -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 & 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">
|
||||
|
||||
Reference in new issue
Block a user