From 8c545aca55a6301724313ceb1bb156e3b3efad4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 17 Oct 2025 15:55:21 +0200 Subject: [PATCH] vm management --- app.py | 68 +++++++++++++++++++++++++++++++++++++++++++- static/main.js | 65 ++++++++++++++++++++++++++++++++++++++++++ templates/index.html | 30 +++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index f41fe27..29b6fff 100644 --- a/app.py +++ b/app.py @@ -431,6 +431,72 @@ def api_disable(): ha_node_maint(False, node, log) return jsonify(ok=True, log=log) + +# --- VM/CT admin actions API --- + +def vm_locked(typ: str, node: str, vmid: int) -> bool: + base = f"/nodes/{node}/{typ}/{vmid}" + cur = get_json(["pvesh", "get", f"{base}/status/current"]) or {} + return bool(cur.get("lock")) + +def run_qm_pct(typ: str, vmid: int, subcmd: str) -> subprocess.CompletedProcess: + tool = "qm" if typ == "qemu" else "pct" + return run([tool, subcmd, str(vmid)]) + +@app.get("/api/list-all-vmct") +def api_list_all_vmct(): + meta = cluster_vmct_meta() + nodes = [n.get("node") for n in (api_cluster_data().get("nodes") or []) if n.get("node")] + return jsonify({"all": list(meta.values()), "nodes": sorted(set(nodes))}) + +@app.post("/api/vm-action") +def api_vm_action(): + if os.geteuid() != 0: + return jsonify(ok=False, error="run as root"), 403 + data = request.get_json(force=True, silent=True) or {} + sid = data.get("sid", "") + action = data.get("action", "") + target = data.get("target", "") + + meta = cluster_vmct_meta() + tup = sid_to_tuple(sid, meta) + if not tup: + return jsonify(ok=False, error="bad sid"), 400 + typ, vmid, node = tup + if not node: + return jsonify(ok=False, error="unknown node"), 400 + + if action == "migrate_offline": + action = "migrate" + + try: + if action == "unlock": + if typ != "qemu": + return jsonify(ok=False, error="unlock only for qemu"), 400 + if not vm_locked(typ, node, vmid): + return jsonify(ok=True, msg="not locked") + r = run(["qm", "unlock", str(vmid)]) + return jsonify(ok=(r.returncode == 0), stdout=r.stdout, stderr=r.stderr) + + elif action in ("start", "stop", "shutdown"): + r = run_qm_pct(typ, vmid, action) + return jsonify(ok=(r.returncode == 0), stdout=r.stdout, stderr=r.stderr) + + elif action == "migrate": + if not target or target == node: + return jsonify(ok=False, error="target required and must differ from source"), 400 + base = f"/nodes/{node}/{typ}/{vmid}/migrate" + cmd = ["pvesh", "create", base, "-target", target, "-online", "0"] + res = post_json(cmd) + return jsonify(ok=True, result=res or {}) + + else: + return jsonify(ok=False, error="unknown action"), 400 + + except Exception as e: + return jsonify(ok=False, error=str(e)), 500 + + if __name__ == "__main__": import argparse p = argparse.ArgumentParser() @@ -439,4 +505,4 @@ if __name__ == "__main__": args = p.parse_args() DEFAULT_NODE = args.node host, port = args.bind.split(":") - app.run(host=host, port=int(port), debug=False, threaded=True) + app.run(host=host, port=int(port), debug=False, threaded=True) \ No newline at end of file diff --git a/static/main.js b/static/main.js index 5dd41f6..4939ac9 100644 --- a/static/main.js +++ b/static/main.js @@ -1,4 +1,5 @@ // ------ helpers ------ +const tblVmAdmin = document.querySelector("#vm-admin"); const $ = (q) => document.querySelector(q); function safe(v) { return (v === undefined || v === null || v === '') ? '—' : String(v); } function ensureArr(a) { return Array.isArray(a) ? a : []; } @@ -87,6 +88,7 @@ async function doRefresh() { const d = await fetchSnapshot(); renderSnap(d); if (!doRefresh.didNonHA) { await renderNonHA(); doRefresh.didNonHA = true; } + if (!doRefresh.didAdmin) { await renderVMAdmin(); doRefresh.didAdmin = true; } } btnRefresh.onclick = doRefresh; btnAuto.onclick = () => { @@ -656,3 +658,66 @@ function renderSnap(d) { // initial one-shot load (auto refresh OFF by default) doRefresh().catch(console.error); + + +// ------ VM Admin API/UI ------ +async function fetchAllVmct() { + const r = await fetch('/api/list-all-vmct'); + return await r.json(); +} +async function vmAction(sid, action, target) { + const body = { sid, action }; + if (target) body.target = target; + const r = await fetch('/api/vm-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const d = await r.json(); + if (!d.ok) throw new Error(d.error || 'action failed'); + return d; +} +async function renderVMAdmin() { + const data = await fetchAllVmct(); + const arr = ensureArr(data.all); + const nodes = ensureArr(data.nodes); + const tbody = document.querySelector('#vm-admin tbody'); + if (!arr.length) { setRows(tbody, [rowHTML(['No VMs/CTs'])]); return; } + + const rows = arr.map(x => { + const sid = safe(x.sid), type = safe(x.type), name = safe(x.name), node = safe(x.node); + const st = /running/i.test(x.status || '') ? badge(x.status, 'ok') : badge(x.status || '—', 'dark'); + const actions = ` +
+ + + + + +
`; + const sel = ``; + return rowHTML([sid, type.toUpperCase(), name, node, st, actions, sel], `data-sid="${sid}"`); + }); + + setRows(tbody, rows); + + Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { + const sid = tr.getAttribute('data-sid'); + const getTarget = () => tr.querySelector('.target-node')?.value || ''; + const bind = (sel, action, needsTarget = false) => { + const btn = tr.querySelector(sel); + if (!btn) return; + btn.onclick = async () => { + btn.disabled = true; + try { + await vmAction(sid, action, needsTarget ? getTarget() : undefined); + await doRefresh(); + } catch (e) { alert('ERROR: ' + e.message); } + btn.disabled = false; + }; + }; + bind('.act-unlock', 'unlock'); + bind('.act-start', 'start'); + bind('.act-stop', 'stop'); + bind('.act-shutdown', 'shutdown'); + bind('.act-migrate', 'migrate', true); + }); +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index b69fa3a..eeff526 100644 --- a/templates/index.html +++ b/templates/index.html @@ -65,6 +65,9 @@ + @@ -227,6 +230,33 @@ + +
+
+
+ + + + + + + + + + + + + + + + + +
SIDTypeNameNodeStatusActionsTarget (migrate)
Loading…
+
Akcje: Unlock (qm), Start/Stop/Shutdown, Offline migrate.
+
+
+
+