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)
|
response = h.handleUpdate(msg)
|
||||||
case "zpoolscrub":
|
case "zpoolscrub":
|
||||||
response = h.handleZpoolScrub(msg)
|
response = h.handleZpoolScrub(msg)
|
||||||
|
case "proxmox_action":
|
||||||
|
response = h.handleProxmoxAction(msg)
|
||||||
case "pty_start":
|
case "pty_start":
|
||||||
response = h.handlePTYStart(msg)
|
response = h.handlePTYStart(msg)
|
||||||
case "pty_stop":
|
case "pty_stop":
|
||||||
@ -481,3 +483,65 @@ func (h *Handler) handleZpoolScrub(msg Message) Message {
|
|||||||
}
|
}
|
||||||
return response
|
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))
|
mux.HandleFunc("DELETE /api/v1/agents/{id}/wireguard/peers/{uuid}", h.wgDeletePeer(hub))
|
||||||
|
|
||||||
// Backup-Endpoints
|
// 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("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", h.listBackups())
|
||||||
mux.HandleFunc("GET /api/v1/agents/{id}/backups/{backup_id}", h.getBackup())
|
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
|
// 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 {
|
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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@ -224,6 +224,10 @@ class ApiClient {
|
|||||||
return this.post(`/api/v1/agents/${agentId}/exec`, { command, timeout })
|
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
|
// Users
|
||||||
getUsers() {
|
getUsers() {
|
||||||
return this.get('/api/v1/users')
|
return this.get('/api/v1/users')
|
||||||
|
|||||||
@ -117,9 +117,9 @@ export default function ProxmoxPanel({ agentId, customers, onClose, onReload })
|
|||||||
) : tab === 'overview' ? (
|
) : tab === 'overview' ? (
|
||||||
<OverviewTab sys={sys} proxmox={proxmox} />
|
<OverviewTab sys={sys} proxmox={proxmox} />
|
||||||
) : tab === 'vms' ? (
|
) : tab === 'vms' ? (
|
||||||
<VmsTab proxmox={proxmox} />
|
<VmsTab proxmox={proxmox} agentId={agentId} />
|
||||||
) : tab === 'containers' ? (
|
) : tab === 'containers' ? (
|
||||||
<ContainersTab proxmox={proxmox} />
|
<ContainersTab proxmox={proxmox} agentId={agentId} />
|
||||||
) : tab === 'storage' ? (
|
) : tab === 'storage' ? (
|
||||||
<StorageTab proxmox={proxmox} />
|
<StorageTab proxmox={proxmox} />
|
||||||
) : tab === 'zfs' ? (
|
) : tab === 'zfs' ? (
|
||||||
@ -617,16 +617,76 @@ function OverviewTab({ sys, proxmox }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function VmsTab({ proxmox }) {
|
function VmActionButtons({ status, loading, onStart, onStop, onShutdown }) {
|
||||||
const vms = proxmox.vms || []
|
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 || []
|
||||||
|
const [pending, setPending] = useState({}) // vmid -> true wenn Action läuft
|
||||||
|
const [errors, setErrors] = useState({})
|
||||||
|
|
||||||
// Sort: running first, then by name
|
|
||||||
const sortedVms = [...vms].sort((a, b) => {
|
const sortedVms = [...vms].sort((a, b) => {
|
||||||
if (a.status === 'running' && b.status !== 'running') return -1
|
if (a.status === 'running' && b.status !== 'running') return -1
|
||||||
if (a.status !== 'running' && b.status === 'running') return 1
|
if (a.status !== 'running' && b.status === 'running') return 1
|
||||||
return a.name.localeCompare(b.name)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3 className="text-sm font-medium text-gray-300">Virtuelle Maschinen ({vms.length})</h3>
|
<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">RAM</th>
|
||||||
<th className="px-3 py-2">Disk</th>
|
<th className="px-3 py-2">Disk</th>
|
||||||
<th className="px-3 py-2">Uptime</th>
|
<th className="px-3 py-2">Uptime</th>
|
||||||
|
<th className="px-3 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-700/50">
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
@ -653,6 +714,7 @@ function VmsTab({ proxmox }) {
|
|||||||
<td className="px-3 py-2 text-white font-medium">{vm.name}</td>
|
<td className="px-3 py-2 text-white font-medium">{vm.name}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<StatusBadge status={vm.status} />
|
<StatusBadge status={vm.status} />
|
||||||
|
{errors[vm.vmid] && <div className="text-red-400 text-xs mt-1">{errors[vm.vmid]}</div>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-gray-400">
|
<td className="px-3 py-2 text-gray-400">
|
||||||
{vm.cpu_usage != null ? `${vm.cpu_usage.toFixed(1)}%` : '—'}
|
{vm.cpu_usage != null ? `${vm.cpu_usage.toFixed(1)}%` : '—'}
|
||||||
@ -668,6 +730,15 @@ function VmsTab({ proxmox }) {
|
|||||||
<td className="px-3 py-2 text-gray-400">
|
<td className="px-3 py-2 text-gray-400">
|
||||||
{vm.uptime ? formatUptime(vm.uptime) : '—'}
|
{vm.uptime ? formatUptime(vm.uptime) : '—'}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -678,16 +749,29 @@ function VmsTab({ proxmox }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContainersTab({ proxmox }) {
|
function ContainersTab({ proxmox, agentId }) {
|
||||||
const containers = proxmox.containers || []
|
const containers = proxmox.containers || []
|
||||||
|
const [pending, setPending] = useState({})
|
||||||
|
const [errors, setErrors] = useState({})
|
||||||
|
|
||||||
// Sort: running first, then by name
|
|
||||||
const sortedCt = [...containers].sort((a, b) => {
|
const sortedCt = [...containers].sort((a, b) => {
|
||||||
if (a.status === 'running' && b.status !== 'running') return -1
|
if (a.status === 'running' && b.status !== 'running') return -1
|
||||||
if (a.status !== 'running' && b.status === 'running') return 1
|
if (a.status !== 'running' && b.status === 'running') return 1
|
||||||
return a.name.localeCompare(b.name)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3 className="text-sm font-medium text-gray-300">Container ({containers.length})</h3>
|
<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">CPU</th>
|
||||||
<th className="px-3 py-2">RAM</th>
|
<th className="px-3 py-2">RAM</th>
|
||||||
<th className="px-3 py-2">Uptime</th>
|
<th className="px-3 py-2">Uptime</th>
|
||||||
|
<th className="px-3 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-700/50">
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
@ -713,6 +798,7 @@ function ContainersTab({ proxmox }) {
|
|||||||
<td className="px-3 py-2 text-white font-medium">{ct.name}</td>
|
<td className="px-3 py-2 text-white font-medium">{ct.name}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<StatusBadge status={ct.status} />
|
<StatusBadge status={ct.status} />
|
||||||
|
{errors[ct.vmid] && <div className="text-red-400 text-xs mt-1">{errors[ct.vmid]}</div>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-gray-400">
|
<td className="px-3 py-2 text-gray-400">
|
||||||
{ct.cpu_usage != null ? `${ct.cpu_usage.toFixed(1)}%` : '—'}
|
{ct.cpu_usage != null ? `${ct.cpu_usage.toFixed(1)}%` : '—'}
|
||||||
@ -724,6 +810,14 @@ function ContainersTab({ proxmox }) {
|
|||||||
<td className="px-3 py-2 text-gray-400">
|
<td className="px-3 py-2 text-gray-400">
|
||||||
{ct.uptime ? formatUptime(ct.uptime) : '—'}
|
{ct.uptime ? formatUptime(ct.uptime) : '—'}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user