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
This commit is contained in:
cynfo3000 2026-03-09 20:22:45 +01:00
parent 4cd9764cec
commit 291d2e3c17
4 changed files with 204 additions and 11 deletions

View File

@ -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
}

View File

@ -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) {

View File

@ -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')

View File

@ -117,9 +117,9 @@ export default function ProxmoxPanel({ agentId, customers, onClose, onReload })
) : tab === 'overview' ? (
<OverviewTab sys={sys} proxmox={proxmox} />
) : tab === 'vms' ? (
<VmsTab proxmox={proxmox} />
<VmsTab proxmox={proxmox} agentId={agentId} />
) : tab === 'containers' ? (
<ContainersTab proxmox={proxmox} />
<ContainersTab proxmox={proxmox} agentId={agentId} />
) : tab === 'storage' ? (
<StorageTab proxmox={proxmox} />
) : 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 <span className="text-xs text-gray-500 animate-pulse">...</span>
}
return (
<div className="flex items-center gap-1">
{isStopped && (
<button
onClick={onStart}
title="Starten"
className="p-1 rounded text-green-400 hover:bg-green-900/40 hover:text-green-300 transition-colors"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M6.3 2.84A1.5 1.5 0 004 4.11v11.78a1.5 1.5 0 002.3 1.27l9.344-5.891a1.5 1.5 0 000-2.538L6.3 2.84z" clipRule="evenodd" />
</svg>
</button>
)}
{isRunning && onShutdown && (
<button
onClick={onShutdown}
title="Graceful Shutdown"
className="p-1 rounded text-yellow-400 hover:bg-yellow-900/40 hover:text-yellow-300 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
</svg>
</button>
)}
{isRunning && (
<button
onClick={onStop}
title="Hard Stop"
className="p-1 rounded text-red-400 hover:bg-red-900/40 hover:text-red-300 transition-colors"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 4h12v12H4V4z" clipRule="evenodd" />
</svg>
</button>
)}
</div>
)
}
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 (
<>
<h3 className="text-sm font-medium text-gray-300">Virtuelle Maschinen ({vms.length})</h3>
@ -644,6 +704,7 @@ function VmsTab({ proxmox }) {
<th className="px-3 py-2">RAM</th>
<th className="px-3 py-2">Disk</th>
<th className="px-3 py-2">Uptime</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
@ -653,21 +714,31 @@ function VmsTab({ proxmox }) {
<td className="px-3 py-2 text-white font-medium">{vm.name}</td>
<td className="px-3 py-2">
<StatusBadge status={vm.status} />
{errors[vm.vmid] && <div className="text-red-400 text-xs mt-1">{errors[vm.vmid]}</div>}
</td>
<td className="px-3 py-2 text-gray-400">
{vm.cpu_usage != null ? `${vm.cpu_usage.toFixed(1)}%` : '—'}
</td>
<td className="px-3 py-2 text-gray-400">
{vm.memory_used && vm.memory_max ?
{vm.memory_used && vm.memory_max ?
`${formatBytes(vm.memory_used)} / ${formatBytes(vm.memory_max)}` : '—'}
</td>
<td className="px-3 py-2 text-gray-400">
{vm.disk_used != null && vm.disk_max ?
{vm.disk_used != null && vm.disk_max ?
`${formatBytes(vm.disk_used)} / ${formatBytes(vm.disk_max)}` : '—'}
</td>
<td className="px-3 py-2 text-gray-400">
{vm.uptime ? formatUptime(vm.uptime) : '—'}
</td>
<td className="px-3 py-2">
<VmActionButtons
status={vm.status}
loading={!!pending[vm.vmid]}
onStart={() => doAction(vm.vmid, 'start')}
onStop={() => doAction(vm.vmid, 'stop')}
onShutdown={() => doAction(vm.vmid, 'shutdown')}
/>
</td>
</tr>
))}
</tbody>
@ -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 (
<>
<h3 className="text-sm font-medium text-gray-300">Container ({containers.length})</h3>
@ -704,6 +788,7 @@ function ContainersTab({ proxmox }) {
<th className="px-3 py-2">CPU</th>
<th className="px-3 py-2">RAM</th>
<th className="px-3 py-2">Uptime</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
@ -713,17 +798,26 @@ function ContainersTab({ proxmox }) {
<td className="px-3 py-2 text-white font-medium">{ct.name}</td>
<td className="px-3 py-2">
<StatusBadge status={ct.status} />
{errors[ct.vmid] && <div className="text-red-400 text-xs mt-1">{errors[ct.vmid]}</div>}
</td>
<td className="px-3 py-2 text-gray-400">
{ct.cpu_usage != null ? `${ct.cpu_usage.toFixed(1)}%` : '—'}
</td>
<td className="px-3 py-2 text-gray-400">
{ct.memory_used && ct.memory_max ?
{ct.memory_used && ct.memory_max ?
`${formatBytes(ct.memory_used)} / ${formatBytes(ct.memory_max)}` : '—'}
</td>
<td className="px-3 py-2 text-gray-400">
{ct.uptime ? formatUptime(ct.uptime) : '—'}
</td>
<td className="px-3 py-2">
<VmActionButtons
status={ct.status}
loading={!!pending[ct.vmid]}
onStart={() => doAction(ct.vmid, 'start')}
onStop={() => doAction(ct.vmid, 'stop')}
/>
</td>
</tr>
))}
</tbody>