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:
parent
4cd9764cec
commit
291d2e3c17
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user