Add a graceful Shutdown action alongside Proxmox's Start/Restart/Stop

Stop maps to Proxmox's hard power-off (/status/stop) — fine for a
crashed guest, but risky for anything with a filesystem that'd rather
flush cleanly first. Proxmox exposes a separate /status/shutdown
endpoint that asks the guest to power itself down (ACPI event for a
VM, SIGTERM-then-wait for a container), so add it as its own action
rather than overloading Stop.

- New shutdownGuest() on the Proxmox adapter, calling /status/shutdown.
- The existing generic action route/loop already dispatches by
  ${action}Guest, so adding "shutdown" to that list was enough on the
  server side — no new route needed.
- New Shutdown button next to Restart/Stop on both the Integrations
  page's guest table and a Proxmox-linked server's detail page.
  Stop's confirm prompt now explicitly points at Shutdown as the
  gentler alternative.

Verified end-to-end against a mock Proxmox server: the shutdown call
hits /status/shutdown (never /status/stop) and is audit-logged as
shutdown_guest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 20:52:31 +02:00
1 parent de9b6a2d6a
commit a6ab69316d
5 files changed
+23 -6

No files matched your search

+4 -1
View File
@@ -62,6 +62,8 @@ export interface ProxmoxAdapter {
startGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>; startGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
stopGuest(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>; restartGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
/** Graceful shutdown (ACPI power event for a VM, SIGTERM-then-wait for a container) — unlike stopGuest, this asks the guest OS to shut itself down. */
shutdownGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
} }
function parseSizeToBytes(size: string): number | null { function parseSizeToBytes(size: string): number | null {
@@ -267,6 +269,7 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
const startGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "start"); 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 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"); const restartGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "reboot");
const shutdownGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "shutdown");
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
const start = Date.now(); const start = Date.now();
@@ -278,5 +281,5 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
} }
} }
return { ping, listGuests, getGuestDetail, startGuest, stopGuest, restartGuest }; return { ping, listGuests, getGuestDetail, startGuest, stopGuest, restartGuest, shutdownGuest };
} }
+1 -1
View File
@@ -627,7 +627,7 @@ integrationsRouter.get("/:id/proxmox/guests", asyncHandler(async (req, res) => {
} }
})); }));
const proxmoxActions = ["start", "stop", "restart"] as const; const proxmoxActions = ["start", "stop", "restart", "shutdown"] as const;
for (const action of proxmoxActions) { for (const action of proxmoxActions) {
integrationsRouter.post( integrationsRouter.post(
+2
View File
@@ -643,6 +643,8 @@ export const api = {
request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/stop`, { method: "POST" }), request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/stop`, { method: "POST" }),
restart: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) => restart: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/restart`, { method: "POST" }), request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/restart`, { method: "POST" }),
shutdown: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/shutdown`, { method: "POST" }),
}, },
synology: { synology: {
storage: (integrationId: number) => request<SynologyStorageResponse>(`/api/integrations/${integrationId}/synology/storage`), storage: (integrationId: number) => request<SynologyStorageResponse>(`/api/integrations/${integrationId}/synology/storage`),
+10 -2
View File
@@ -361,9 +361,10 @@ export default function Integrations({ user }: { user: CurrentUser }) {
} }
} }
async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart") { async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart" | "shutdown") {
if (!selectedId) return; if (!selectedId) return;
if (action === "stop" && !confirm(`Stop ${g.type === "qemu" ? "VM" : "container"} "${g.name}"?`)) return; if (action === "stop" && !confirm(`Stop ${g.type === "qemu" ? "VM" : "container"} "${g.name}" immediately? Use Shutdown instead for a graceful power-off.`))
return;
setActingOnGuest(g.vmid); setActingOnGuest(g.vmid);
setProxmoxError(null); setProxmoxError(null);
try { try {
@@ -845,6 +846,13 @@ export default function Integrations({ user }: { user: CurrentUser }) {
> >
Restart Restart
</button> </button>
<button
className="btn btn-sm"
onClick={() => guestAction(g, "shutdown")}
disabled={actingOnGuest === g.vmid}
>
Shutdown
</button>
<button <button
className="btn btn-sm btn-outline-danger" className="btn btn-sm btn-outline-danger"
onClick={() => guestAction(g, "stop")} onClick={() => guestAction(g, "stop")}
+6 -2
View File
@@ -230,9 +230,10 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
const { server, hardware, ipAddresses, dnsMatches } = detail; const { server, hardware, ipAddresses, dnsMatches } = detail;
async function guestAction(action: "start" | "stop" | "restart") { async function guestAction(action: "start" | "stop" | "restart" | "shutdown") {
if (!server.proxmoxIntegrationId || !server.proxmoxNode || !server.proxmoxGuestType || server.proxmoxVmid === null) return; if (!server.proxmoxIntegrationId || !server.proxmoxNode || !server.proxmoxGuestType || server.proxmoxVmid === null) return;
if (action === "stop" && !confirm(`Stop ${server.name}? This is an immediate power-off, not a graceful shutdown.`)) return; if (action === "stop" && !confirm(`Stop ${server.name} immediately? This is a hard power-off, not a graceful shutdown — use Shutdown instead if the guest OS should get a chance to close down cleanly.`))
return;
setActingOnGuest(true); setActingOnGuest(true);
setGuestActionError(null); setGuestActionError(null);
try { try {
@@ -371,6 +372,9 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
<button className="btn btn-sm" onClick={() => guestAction("restart")} disabled={actingOnGuest}> <button className="btn btn-sm" onClick={() => guestAction("restart")} disabled={actingOnGuest}>
Restart Restart
</button> </button>
<button className="btn btn-sm" onClick={() => guestAction("shutdown")} disabled={actingOnGuest}>
Shutdown
</button>
<button <button
className="btn btn-sm btn-outline-danger" className="btn btn-sm btn-outline-danger"
onClick={() => guestAction("stop")} onClick={() => guestAction("stop")}