From 291d2e3c177d303b27af66bd98a9a68cf5c196ca Mon Sep 17 00:00:00 2001 From: cynfo3000 Date: Mon, 9 Mar 2026 20:22:45 +0100 Subject: [PATCH] feat: VM/CT Start/Stop im Frontend + Agent proxmox_action Handler - Agent: neuer 'proxmox_action' Command (start/stop/shutdown fuer VMs und LXC) - Backend: POST /api/v1/agents/{id}/proxmox/action Endpoint - Frontend: Start/Stop/Shutdown Buttons in VMs- und Container-Tab - Play (gruen) = starten (nur wenn gestoppt) - Power (gelb) = Graceful Shutdown (nur VMs, nur wenn running) - Stop (rot) = Hard Stop (wenn running) - api/client.js: proxmoxAction() Methode --- agent-linux/ws/handler.go | 64 +++++++++++++ backend/api/tunnel_proxy.go | 31 ++++++ frontend/src/api/client.js | 4 + frontend/src/components/ProxmoxPanel.jsx | 116 ++++++++++++++++++++--- 4 files changed, 204 insertions(+), 11 deletions(-) diff --git a/agent-linux/ws/handler.go b/agent-linux/ws/handler.go index d90d79b..b82cf02 100644 --- a/agent-linux/ws/handler.go +++ b/agent-linux/ws/handler.go @@ -47,6 +47,8 @@ func (h *Handler) HandleCommand(msg Message) { response = h.handleUpdate(msg) case "zpoolscrub": response = h.handleZpoolScrub(msg) + case "proxmox_action": + response = h.handleProxmoxAction(msg) case "pty_start": response = h.handlePTYStart(msg) case "pty_stop": @@ -481,3 +483,65 @@ func (h *Handler) handleZpoolScrub(msg Message) Message { } return response } + +// handleProxmoxAction: VM/CT starten oder stoppen via pvesh +func (h *Handler) handleProxmoxAction(msg Message) Message { + response := Message{Type: "response", ID: msg.ID} + + vmType, _ := msg.Params["type"].(string) // "vm" oder "ct" + action, _ := msg.Params["action"].(string) // "start", "stop", "shutdown" + vmidFloat, ok := msg.Params["vmid"].(float64) + if !ok { + response.Status = "error" + response.Error = "Parameter 'vmid' erforderlich" + return response + } + vmid := int(vmidFloat) + + if vmType != "vm" && vmType != "ct" { + response.Status = "error" + response.Error = "Parameter 'type' muss 'vm' oder 'ct' sein" + return response + } + if action != "start" && action != "stop" && action != "shutdown" { + response.Status = "error" + response.Error = "Parameter 'action' muss 'start', 'stop' oder 'shutdown' sein" + return response + } + + // Node-Name ermitteln + nodeName, _ := os.Hostname() + if nodeName == "" { + nodeName = "localhost" + } + + // pvesh-Pfad aufbauen + var apiPath string + if vmType == "vm" { + apiPath = fmt.Sprintf("/nodes/%s/qemu/%d/status/%s", nodeName, vmid, action) + } else { + if action == "shutdown" { + action = "stop" // LXC kennt kein "shutdown", stop reicht + } + apiPath = fmt.Sprintf("/nodes/%s/lxc/%d/status/%s", nodeName, vmid, action) + } + + log.Printf("Proxmox Action: pvesh create %s", apiPath) + + cmd := exec.Command("/usr/bin/pvesh", "create", apiPath, "--output-format", "json") + output, err := cmd.CombinedOutput() + if err != nil { + log.Printf("Proxmox Action Fehler: %v — %s", err, string(output)) + response.Status = "error" + response.Error = fmt.Sprintf("pvesh fehlgeschlagen: %v — %s", err, strings.TrimSpace(string(output))) + return response + } + + log.Printf("Proxmox Action erfolgreich: %s %s %d", action, vmType, vmid) + response.Status = "ok" + response.Data = map[string]interface{}{ + "message": fmt.Sprintf("%s %d: %s ausgeführt", vmType, vmid, action), + "output": strings.TrimSpace(string(output)), + } + return response +} diff --git a/backend/api/tunnel_proxy.go b/backend/api/tunnel_proxy.go index 9a81180..25e6239 100644 --- a/backend/api/tunnel_proxy.go +++ b/backend/api/tunnel_proxy.go @@ -37,6 +37,7 @@ func (h *Handler) setupTunnelRoutes(mux *http.ServeMux, hub *ws.Hub) { mux.HandleFunc("DELETE /api/v1/agents/{id}/wireguard/peers/{uuid}", h.wgDeletePeer(hub)) // Backup-Endpoints + mux.HandleFunc("POST /api/v1/agents/{id}/proxmox/action", h.proxmoxAction(hub)) mux.HandleFunc("POST /api/v1/agents/{id}/backup", h.triggerBackup(hub)) mux.HandleFunc("GET /api/v1/agents/{id}/backups", h.listBackups()) mux.HandleFunc("GET /api/v1/agents/{id}/backups/{backup_id}", h.getBackup()) @@ -265,6 +266,36 @@ func (h *Handler) agentReboot(hub *ws.Hub) http.HandlerFunc { } } +// proxmoxAction: VM/CT starten oder stoppen +func (h *Handler) proxmoxAction(hub *ws.Hub) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + + var req struct { + Type string `json:"type"` // "vm" oder "ct" + VMID int `json:"vmid"` + Action string `json:"action"` // "start", "stop", "shutdown" + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Ungültiger Request-Body") + return + } + if req.VMID <= 0 || req.Type == "" || req.Action == "" { + writeError(w, http.StatusBadRequest, "type, vmid und action erforderlich") + return + } + + params := map[string]interface{}{ + "type": req.Type, + "vmid": float64(req.VMID), + "action": req.Action, + } + + h.auditLog(r, "proxmox_action", "agent", agentID, "", fmt.Sprintf("%s %s %d", req.Action, req.Type, req.VMID)) + h.sendCommandAndWait(hub, agentID, "proxmox_action", params, 60, w) + } +} + // sendAgentCommand erstellt einen einfachen Command-Handler ohne Request-Body func (h *Handler) sendAgentCommand(hub *ws.Hub, command string, params map[string]interface{}, timeoutSec int) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 466359f..9eb94c6 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -224,6 +224,10 @@ class ApiClient { return this.post(`/api/v1/agents/${agentId}/exec`, { command, timeout }) } + proxmoxAction(agentId, type, vmid, action) { + return this.post(`/api/v1/agents/${agentId}/proxmox/action`, { type, vmid, action }) + } + // Users getUsers() { return this.get('/api/v1/users') diff --git a/frontend/src/components/ProxmoxPanel.jsx b/frontend/src/components/ProxmoxPanel.jsx index af69f3f..502f15c 100644 --- a/frontend/src/components/ProxmoxPanel.jsx +++ b/frontend/src/components/ProxmoxPanel.jsx @@ -117,9 +117,9 @@ export default function ProxmoxPanel({ agentId, customers, onClose, onReload }) ) : tab === 'overview' ? ( ) : tab === 'vms' ? ( - + ) : tab === 'containers' ? ( - + ) : tab === 'storage' ? ( ) : tab === 'zfs' ? ( @@ -617,16 +617,76 @@ function OverviewTab({ sys, proxmox }) { ) } -function VmsTab({ proxmox }) { +function VmActionButtons({ status, loading, onStart, onStop, onShutdown }) { + const isRunning = status === 'running' + const isStopped = status === 'stopped' + + if (loading) { + return ... + } + + return ( +
+ {isStopped && ( + + )} + {isRunning && onShutdown && ( + + )} + {isRunning && ( + + )} +
+ ) +} + +function VmsTab({ proxmox, agentId }) { const vms = proxmox.vms || [] - - // Sort: running first, then by name + const [pending, setPending] = useState({}) // vmid -> true wenn Action läuft + const [errors, setErrors] = useState({}) + const sortedVms = [...vms].sort((a, b) => { if (a.status === 'running' && b.status !== 'running') return -1 if (a.status !== 'running' && b.status === 'running') return 1 return a.name.localeCompare(b.name) }) + const doAction = async (vmid, action) => { + setPending(p => ({ ...p, [vmid]: true })) + setErrors(e => ({ ...e, [vmid]: null })) + try { + await api.proxmoxAction(agentId, 'vm', vmid, action) + } catch (err) { + setErrors(e => ({ ...e, [vmid]: err.message })) + } finally { + setPending(p => ({ ...p, [vmid]: false })) + } + } + return ( <>

Virtuelle Maschinen ({vms.length})

@@ -644,6 +704,7 @@ function VmsTab({ proxmox }) { RAM Disk Uptime + @@ -653,21 +714,31 @@ function VmsTab({ proxmox }) { {vm.name} + {errors[vm.vmid] &&
{errors[vm.vmid]}
} {vm.cpu_usage != null ? `${vm.cpu_usage.toFixed(1)}%` : '—'} - {vm.memory_used && vm.memory_max ? + {vm.memory_used && vm.memory_max ? `${formatBytes(vm.memory_used)} / ${formatBytes(vm.memory_max)}` : '—'} - {vm.disk_used != null && vm.disk_max ? + {vm.disk_used != null && vm.disk_max ? `${formatBytes(vm.disk_used)} / ${formatBytes(vm.disk_max)}` : '—'} {vm.uptime ? formatUptime(vm.uptime) : '—'} + + doAction(vm.vmid, 'start')} + onStop={() => doAction(vm.vmid, 'stop')} + onShutdown={() => doAction(vm.vmid, 'shutdown')} + /> + ))} @@ -678,16 +749,29 @@ function VmsTab({ proxmox }) { ) } -function ContainersTab({ proxmox }) { +function ContainersTab({ proxmox, agentId }) { const containers = proxmox.containers || [] - - // Sort: running first, then by name + const [pending, setPending] = useState({}) + const [errors, setErrors] = useState({}) + const sortedCt = [...containers].sort((a, b) => { if (a.status === 'running' && b.status !== 'running') return -1 if (a.status !== 'running' && b.status === 'running') return 1 return a.name.localeCompare(b.name) }) + const doAction = async (vmid, action) => { + setPending(p => ({ ...p, [vmid]: true })) + setErrors(e => ({ ...e, [vmid]: null })) + try { + await api.proxmoxAction(agentId, 'ct', vmid, action) + } catch (err) { + setErrors(e => ({ ...e, [vmid]: err.message })) + } finally { + setPending(p => ({ ...p, [vmid]: false })) + } + } + return ( <>

Container ({containers.length})

@@ -704,6 +788,7 @@ function ContainersTab({ proxmox }) { CPU RAM Uptime + @@ -713,17 +798,26 @@ function ContainersTab({ proxmox }) { {ct.name} + {errors[ct.vmid] &&
{errors[ct.vmid]}
} {ct.cpu_usage != null ? `${ct.cpu_usage.toFixed(1)}%` : '—'} - {ct.memory_used && ct.memory_max ? + {ct.memory_used && ct.memory_max ? `${formatBytes(ct.memory_used)} / ${formatBytes(ct.memory_max)}` : '—'} {ct.uptime ? formatUptime(ct.uptime) : '—'} + + doAction(ct.vmid, 'start')} + onStop={() => doAction(ct.vmid, 'stop')} + /> + ))}