From 2682a0ff4f40ec2d2d0ba630da0eddb2828d9b90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 20:58:51 +0200 Subject: [PATCH 01/28] refator_comm1 --- app.py | 182 ++++---- static/js/admin.js | 150 +++++++ static/js/api.js | 23 + static/js/helpers.js | 53 +++ static/js/main.js | 87 ++++ static/js/nodeDetail.js | 107 +++++ static/js/tables.js | 191 +++++++++ static/js/vmDetail.js | 136 ++++++ static/main.js | 900 ---------------------------------------- static/styles.css | 45 ++ templates/index.html | 57 +-- 11 files changed, 896 insertions(+), 1035 deletions(-) create mode 100644 static/js/admin.js create mode 100644 static/js/api.js create mode 100644 static/js/helpers.js create mode 100644 static/js/main.js create mode 100644 static/js/nodeDetail.js create mode 100644 static/js/tables.js create mode 100644 static/js/vmDetail.js delete mode 100644 static/main.js diff --git a/app.py b/app.py index 0afe3da..14fc8ed 100644 --- a/app.py +++ b/app.py @@ -64,7 +64,7 @@ def stop_if_running(unit: str, out: List[str]) -> None: def ha_node_maint(enable: bool, node: str, out: List[str]) -> None: cmd = ["ha-manager", "crm-command", "node-maintenance", "enable" if enable else "disable", node] out.append("$ " + " ".join(shlex.quote(x) for x in cmd)) - r = run(cmd, timeout=timeout or 25) + r = run(cmd, timeout=25) if r.returncode != 0: out.append(f"ERR: {r.stderr.strip()}") @@ -169,11 +169,11 @@ def cluster_vmct_index() -> Dict[str, str]: items = get_json(["pvesh", "get", "/cluster/resources"]) or [] idx: Dict[str, str] = {} for it in items: - t = it.get("type") - if t not in ("qemu", "lxc"): continue - vmid = it.get("vmid"); node = it.get("node") - sid = (("vm" if t=="qemu" else "ct") + f":{vmid}") if vmid is not None else norm_sid(it.get("id")) - if sid and node: idx[sid] = node + t = it.get("type") + if t not in ("qemu", "lxc"): continue + vmid = it.get("vmid"); node = it.get("node") + sid = (("vm" if t=="qemu" else "ct") + f":{vmid}") if vmid is not None else norm_sid(it.get("id")) + if sid and node: idx[sid] = node return idx def cluster_vmct_meta() -> Dict[str, Dict[str, Any]]: @@ -195,34 +195,21 @@ def merge_resources(api_res: List[Dict[str, Any]], parsed_res: List[Dict[str, Any]], vmct_idx: Dict[str, str]) -> List[Dict[str, Any]]: by_sid: Dict[str, Dict[str, Any]] = {} - - # seed from /cluster/ha/resources for r in (api_res or []): sid = norm_sid(r.get("sid")) - if not sid: - continue - x = dict(r) - x["sid"] = sid - by_sid[sid] = x - - # merge runtime from ha-manager + if not sid: continue + x = dict(r); x["sid"] = sid; by_sid[sid] = x for r in (parsed_res or []): sid = norm_sid(r.get("sid")) - if not sid: - continue + if not sid: continue x = by_sid.get(sid, {"sid": sid}) for k, v in r.items(): - if k == "sid": - continue - if v not in (None, ""): - x[k] = v + if k == "sid": continue + if v not in (None, ""): x[k] = v by_sid[sid] = x - - # fill node from /cluster/resources for sid, x in by_sid.items(): if not x.get("node") and sid in vmct_idx: x["node"] = vmct_idx[sid] - return list(by_sid.values()) # ---------------- VM details ---------------- @@ -265,20 +252,11 @@ def node_detail_payload(name: str) -> Dict[str, Any]: timeinfo = get_json(["pvesh", "get", f"/nodes/{name}/time"]) or {} services = get_json(["pvesh", "get", f"/nodes/{name}/services"]) or [] network_cfg = get_json(["pvesh", "get", f"/nodes/{name}/network"]) or [] - netstat = get_json(["pvesh", "get", f"/nodes/{name}/netstat"]) or [] # may be empty on some versions + netstat = get_json(["pvesh", "get", f"/nodes/{name}/netstat"]) or [] disks = get_json(["pvesh", "get", f"/nodes/{name}/disks/list"]) or [] subscription = get_json(["pvesh", "get", f"/nodes/{name}/subscription"]) or {} - return { - "node": name, - "status": status, - "version": version, - "time": timeinfo, - "services": services, - "network_cfg": network_cfg, - "netstat": netstat, - "disks": disks, - "subscription": subscription - } + return {"node": name,"status": status,"version": version,"time": timeinfo,"services": services, + "network_cfg": network_cfg,"netstat": netstat,"disks": disks,"subscription": subscription} def node_ha_services(node: str) -> Dict[str, str]: svcs = get_json(["pvesh", "get", f"/nodes/{node}/services"]) or [] @@ -294,40 +272,20 @@ def units_for_node(node: str) -> Dict[str, str]: wanted = {"watchdog-mux", "pve-ha-crm", "pve-ha-lrm"} svc = get_json(["pvesh", "get", f"/nodes/{node}/services"]) or [] states: Dict[str, str] = {} - def norm_state(s: dict) -> str: - raw_active = str( - s.get("active", "") or - s.get("active-state", "") or - s.get("ActiveState", "") or - s.get("activestate", "") - ).lower() - - status = str(s.get("status", "")).lower() - substate = str(s.get("substate", "")).lower() - state = str(s.get("state", "")).lower() - - any_active = ( - raw_active in ("active", "running", "1", "true") or - status in ("active", "running") or - substate in ("running", "active") or - ("running" in state or "active" in state) - ) + raw_active = str((s.get("active","") or s.get("active-state","") or s.get("ActiveState","") or s.get("activestate",""))).lower() + status = str(s.get("status","")).lower() + substate = str(s.get("substate","")).lower() + state = str(s.get("state","")).lower() + any_active = (raw_active in ("active","running","1","true") or status in ("active","running") or substate in ("running","active") or ("running" in state or "active" in state)) return "active" if any_active else "inactive" - for s in svc: - name_raw = (s.get("name") or "") - name = re.sub(r"\.service$", "", name_raw) - if name in wanted: - states[name] = norm_state(s) - + name_raw = (s.get("name") or ""); name = re.sub(r"\.service$","",name_raw) + if name in wanted: states[name] = norm_state(s) for u in wanted: if states.get(u) != "active" and is_active(u): states[u] = "active" - - for u in wanted: - states.setdefault(u, "inactive") - + for u in wanted: states.setdefault(u, "inactive") return states # ---------------- snapshot ---------------- @@ -343,16 +301,12 @@ def status_snapshot(node: str) -> Dict[str, Any]: if not ha_status and parsed.get("nodes"): ha_status = [{ "node": n.get("node"), "state": n.get("state"), - "crm_state": n.get("crm", ""), "lrm_state": n.get("lrm", "") + "crm_state": n.get("crm",""), "lrm_state": n.get("lrm","") } for n in parsed["nodes"]] if not ha_status: for it in api.get("cluster_status", []): if it.get("type") == "node": - ha_status.append({ - "node": it.get("name"), - "state": "online" if it.get("online") else "offline", - "crm_state": "", "lrm_state": "" - }) + ha_status.append({"node": it.get("name"),"state": "online" if it.get("online") else "offline","crm_state":"", "lrm_state":""}) enriched = [] for n in ha_status: @@ -360,23 +314,17 @@ def status_snapshot(node: str) -> Dict[str, Any]: if node_name and (not crm or not lrm): try: svc = node_ha_services(node_name) - if not crm: n["crm_state"] = svc.get("crm_state", "") - if not lrm: n["lrm_state"] = svc.get("lrm_state", "") + if not crm: n["crm_state"] = svc.get("crm_state","") + if not lrm: n["lrm_state"] = svc.get("lrm_state","") except Exception: pass enriched.append(n) api["ha_status"] = enriched - api["ha_resources"] = merge_resources(api.get("ha_resources", []), parsed.get("resources", []), vmct_ix) - units = units_for_node(node or socket.gethostname()) - return { - "node_arg": node, "hostname": socket.gethostname(), - "votequorum": vq, "units": units, - "cfgtool": get_cfgtool().strip(), "pvecm": get_pvecm_status().strip(), - "ha_raw": ha_raw, "replication": get_pvesr_status().strip(), - "api": api, "ts": int(time.time()) - } + return {"node_arg": node, "hostname": socket.gethostname(), "votequorum": vq, "units": units, + "cfgtool": get_cfgtool().strip(), "pvecm": get_pvecm_status().strip(), + "ha_raw": ha_raw, "replication": get_pvesr_status().strip(), "api": api, "ts": int(time.time())} # ---------------- web ---------------- @app.get("/") @@ -384,11 +332,67 @@ def index(): node = request.args.get("node", DEFAULT_NODE) return render_template("index.html", title=APP_TITLE, node=node) +# Stary zbiorczy snapshot — zostaje @app.get("/api/info") def api_info(): node = request.args.get("node", DEFAULT_NODE) return jsonify(status_snapshot(node)) +# --- NOWE lżejsze endpointy (szybsze ładowanie strony) --- + +@app.get("/api/cluster") +def api_cluster_brief(): + vq = votequorum_brief() + api = api_cluster_data() + ha_raw = get_ha_status_raw().strip() + parsed = parse_ha_manager(ha_raw) + vmct_ix = cluster_vmct_index() + api["ha_resources"] = merge_resources(api.get("ha_resources", []), parsed.get("resources", []), vmct_ix) + return jsonify({ + "votequorum": vq, + "cluster_status": api.get("cluster_status", []), + "ha_resources": api.get("ha_resources", []), + "pvecm": get_pvecm_status().strip(), + "cfgtool": get_cfgtool().strip(), + "hostname": socket.gethostname(), + "ts": int(time.time()) + }) + +@app.get("/api/nodes/summary") +def api_nodes_summary(): + nodes = enrich_nodes((api_cluster_data().get("nodes") or [])) + return jsonify({ "nodes": nodes }) + +@app.get("/api/units") +def api_units(): + node = request.args.get("node", DEFAULT_NODE) + return jsonify({ "units": units_for_node(node) }) + +# Replication ze wszystkich nodów +@app.get("/api/replication/all") +def api_replication_all(): + jobs: List[Dict[str, Any]] = [] + nodes = [n.get("node") for n in (api_cluster_data().get("nodes") or []) if n.get("node")] + for name in nodes: + # PVE 7/8: GET /nodes/{node}/replication lub /nodes/{node}/replication/jobs + data = get_json(["pvesh", "get", f"/nodes/{name}/replication"]) or get_json(["pvesh","get",f"/nodes/{name}/replication/jobs"]) or [] + # fallback: pvesr status na zdalnym nodzie bywa utrudnione, więc parsujemy standardową strukturę API + for it in (data or []): + jobs.append({ + "node": name, + "job": it.get("id") or it.get("job") or "", + "enabled": "yes" if (it.get("enable",1) in (1, "1", True)) else "no", + "target": it.get("target") or it.get("target-node") or "", + "last": it.get("last_sync") or it.get("last_sync", ""), + "next": it.get("next_sync") or it.get("next_sync", ""), + "dur": it.get("duration") or it.get("duration", ""), + "fail": int(it.get("fail_count") or it.get("failcount") or 0), + "state": it.get("state") or it.get("status") or "" + }) + return jsonify({ "jobs": jobs }) + +# --- istniejące endpointy detali i list --- + @app.get("/api/vm") def api_vm_detail(): sid = request.args.get("sid", "") @@ -404,10 +408,7 @@ def api_list_vmct(): meta = cluster_vmct_meta() ha_sids = {norm_sid(r.get("sid")) for r in (api_cluster_data().get("ha_resources") or []) if r.get("sid")} nonha = [v for k, v in meta.items() if k not in ha_sids] - return jsonify({ - "nonha": nonha, "ha_index": list(ha_sids), - "count_nonha": len(nonha), "count_all_vmct": len(meta) - }) + return jsonify({"nonha": nonha, "ha_index": list(ha_sids), "count_nonha": len(nonha), "count_all_vmct": len(meta)}) @app.post("/api/enable") def api_enable(): @@ -431,8 +432,7 @@ def api_disable(): ha_node_maint(False, node, log) return jsonify(ok=True, log=log) - -# --- VM/CT admin actions API --- +# --- VM/CT admin actions API (pozostaje jak było; bez usuwania) --- def vm_locked(typ: str, node: str, vmid: int) -> bool: base = f"/nodes/{node}/{typ}/{vmid}" @@ -499,8 +499,6 @@ def api_vm_action(): except Exception as e: return jsonify(ok=False, error=str(e)), 500 - - @app.get("/api/task-status") def api_task_status(): upid = request.args.get("upid", "").strip() @@ -510,7 +508,6 @@ def api_task_status(): st = get_json(["pvesh", "get", f"/nodes/{node}/tasks/{upid}/status"]) or {} return jsonify(ok=True, status=st) - @app.get("/api/task-log") def api_task_log(): upid = request.args.get("upid", "").strip() @@ -522,12 +519,9 @@ def api_task_log(): start_i = 0 if not upid or not node: return jsonify(ok=False, error="upid and node required"), 400 - # Returns a list of {n: , t: } lines = get_json(["pvesh", "get", f"/nodes/{node}/tasks/{upid}/log", "-start", str(start_i)]) or [] - # Compute next start next_start = start_i if isinstance(lines, list) and lines: - # find max n try: next_start = max((int(x.get("n", start_i)) for x in lines if isinstance(x, dict)), default=start_i) + 1 except Exception: @@ -542,4 +536,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) \ No newline at end of file + app.run(host=host, port=int(port), debug=False, threaded=True) diff --git a/static/js/admin.js b/static/js/admin.js new file mode 100644 index 0000000..e25fa6c --- /dev/null +++ b/static/js/admin.js @@ -0,0 +1,150 @@ +import { badge, rowHTML, setRows, safe, showToast } from './helpers.js'; +import { api } from './api.js'; + +export async function renderVMAdmin() { + const data = await api.listAllVmct(); + const arr = Array.isArray(data.all) ? data.all : []; + const nodes = Array.isArray(data.nodes) ? data.nodes : []; + const tbody = document.querySelector('#vm-admin tbody'); + if (!arr.length) { setRows(tbody, [rowHTML(['Brak VM/CT'])]); 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 = ``; + const migrateBtn = `
+ +
`; + return rowHTML([sid, type.toUpperCase(), name, node, st, actions, sel, migrateBtn], `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 colSpan = tr.children.length; + + const setRowBusy = (busy) => { + const nameCell = tr.children[2]; if (!nameCell) return; + let spin = nameCell.querySelector('.op-spin'); + if (busy) { + if (!spin) { + const span = document.createElement('span'); + span.className = 'op-spin spinner-border spinner-border-sm align-middle ms-2'; + span.setAttribute('role','status'); span.setAttribute('aria-hidden','true'); + nameCell.appendChild(span); + } + } else { if (spin) spin.remove(); } + }; + + const ensureMigRow = () => { + let nxt = tr.nextElementSibling; + if (!nxt || !nxt.classList.contains('mig-row')) { + nxt = document.createElement('tr'); nxt.className = 'mig-row d-none'; + const td = document.createElement('td'); td.colSpan = colSpan; + td.innerHTML = '
Migration status
'; + nxt.appendChild(td); tr.parentNode.insertBefore(nxt, tr.nextSibling); + } return nxt; + }; + const setMigRowVisible = (row, vis) => row.classList.toggle('d-none', !vis); + + const tailTaskLog = async (upid, node, preEl, stopSignal) => { + let start = 0; const delay = (ms)=>new Promise(r=>setTimeout(r,ms)); + const append = (txt) => { if (!preEl) return; preEl.textContent = (preEl.textContent?preEl.textContent+'\n':'') + txt; preEl.scrollTop = preEl.scrollHeight; }; + while (!stopSignal.done) { + try { + const d = await api.taskLog(upid, node, start); + if (d && d.ok) { + const lines = Array.isArray(d.lines) ? d.lines : []; + for (const ln of lines) if (ln && typeof ln.t === 'string') append(ln.t); + start = d.next_start ?? start; + } + } catch {} + await delay(1500); + } + }; + + const pollTask = async (upid, node, onUpdate, onDone) => { + let lastSt = null; if (!upid || !node) return; + const started = Date.now(); const maxMs = 5*60*1000; + const delay = (ms)=>new Promise(r=>setTimeout(r,ms)); + while (Date.now() - started < maxMs) { + try { + const d = await api.taskStatus(upid, node); + if (d && d.ok) { + const st = d.status || {}; lastSt = st; + try { onUpdate && onUpdate(st); } catch {} + const s = (st.status||'').toLowerCase(); + if (s === 'stopped' || st.exitstatus) break; + } + } catch {} + await delay(2000); + } + try { onDone && onDone(lastSt); } catch {} + }; + + const bind = (selector, action, needsTarget=false) => { + const btn = tr.querySelector(selector); if (!btn) return; + btn.onclick = async () => { + setRowBusy(true); btn.disabled = true; + try { + if (action === 'migrate') { + const target = getTarget(); + const resp = await api.vmAction(sid, action, target); + const row = ensureMigRow(); setMigRowVisible(row, true); + const log = row.querySelector('.mig-log'); + const srcNode = resp.source_node; const upid = resp.upid; const stopSig = { done:false }; + tailTaskLog(upid, srcNode, log, stopSig); + log.textContent = `Starting offline migrate to ${target} (UPID: ${upid || '—'})...`; + const badgeCell = tr.children[4]; if (badgeCell) badgeCell.innerHTML = badge('migrating','info'); + await pollTask(upid, srcNode, (st) => { + const keys = ['type','status','pid','starttime','user','node','endtime','exitstatus']; + const lines = keys.filter(k=>st && st[k]!==undefined).map(k=>`${k}: ${st[k]}`); + log.textContent = lines.join('\n') || '—'; + }, async (finalSt) => { + stopSig.done = true; + const exit = (finalSt && finalSt.exitstatus) ? String(finalSt.exitstatus) : ''; + const ok = exit.toUpperCase() === 'OK'; + const badgeCell = tr.children[4]; + if (badgeCell) badgeCell.innerHTML = ok ? badge('running','ok') : badge('migrate error','err'); + log.textContent += (log.textContent ? '\n' : '') + (ok ? 'Migration finished successfully.' : ('Migration failed: ' + (exit || 'unknown error'))); + setRowBusy(false); + // odśwież minimalnie: tylko ten wiersz przez szybkie /api/list-all-vmct? (tu prosty full-refresh stanu) + try { document.getElementById('btnRefresh').click(); } catch {} + if (ok) setTimeout(()=>{ const row = tr.nextElementSibling; if (row && row.classList.contains('mig-row')) setMigRowVisible(row,false); }, 2000); + }); + } else { + await api.vmAction(sid, action, needsTarget ? getTarget() : undefined); + showToast('Success', `${action} executed for ${sid}`, 'success'); + setRowBusy(false); + try { document.getElementById('btnRefresh').click(); } catch {} + } + } catch (e) { showToast('Error', 'ERROR: ' + (e.message || e), 'danger'); setRowBusy(false); } + btn.disabled = false; + }; + }; + + bind('.act-unlock','unlock'); + bind('.act-start','start'); + bind('.act-stop','stop'); + bind('.act-shutdown','shutdown'); + bind('.act-migrate','migrate', true); + + const statusBtn = tr.querySelector('.act-status'); + if (statusBtn) statusBtn.onclick = () => { + const row = tr.nextElementSibling && tr.nextElementSibling.classList.contains('mig-row') ? tr.nextElementSibling : null; + if (!row) return; + const vis = row.classList.contains('d-none'); row.classList.toggle('d-none', !vis); + }; + }); +} diff --git a/static/js/api.js b/static/js/api.js new file mode 100644 index 0000000..3fbffa3 --- /dev/null +++ b/static/js/api.js @@ -0,0 +1,23 @@ +// Każda funkcja = jedno zapytanie (łatwy prefetch i równoległość) +export const api = { + snapshot: (node) => fetch(`/api/info?node=${encodeURIComponent(node||'')}`).then(r=>r.json()), + + // lżejsze mikro-endpointy: + clusterBrief: () => fetch('/api/cluster').then(r=>r.json()), + nodesSummary: () => fetch('/api/nodes/summary').then(r=>r.json()), + units: (node) => fetch(`/api/units?node=${encodeURIComponent(node||'')}`).then(r=>r.json()), + replicationAll: () => fetch('/api/replication/all').then(r=>r.json()), + + vmDetail: (sid) => fetch('/api/vm?sid=' + encodeURIComponent(sid)).then(r=>r.json()), + nodeDetail: (name) => fetch('/api/node?name=' + encodeURIComponent(name)).then(r=>r.json()), + listNonHA: () => fetch('/api/list-vmct').then(r=>r.json()), + listAllVmct: () => fetch('/api/list-all-vmct').then(r=>r.json()), + + action: (act, node) => fetch('/api/'+act, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({node})}).then(r=>r.json()), + vmAction: (sid, action, target) => { + const body = { sid, action }; if (target) body.target = target; + return fetch('/api/vm-action', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}).then(r=>r.json()); + }, + taskStatus: (upid, node) => fetch(`/api/task-status?upid=${encodeURIComponent(upid)}&node=${encodeURIComponent(node)}`).then(r=>r.json()), + taskLog: (upid, node, start=0) => fetch(`/api/task-log?upid=${encodeURIComponent(upid)}&node=${encodeURIComponent(node)}&start=${start}`).then(r=>r.json()) +}; diff --git a/static/js/helpers.js b/static/js/helpers.js new file mode 100644 index 0000000..2b216c1 --- /dev/null +++ b/static/js/helpers.js @@ -0,0 +1,53 @@ +// ------ helpers ------ +export const $ = (q) => document.querySelector(q); +export function safe(v) { return (v === undefined || v === null || v === '') ? '—' : String(v); } +export function ensureArr(a) { return Array.isArray(a) ? a : []; } +export function pct(p) { if (p == null) return '—'; return (p * 100).toFixed(1) + '%'; } +export function humanBytes(n) { if (n == null) return '—'; const u = ['B','KiB','MiB','GiB','TiB','PiB']; let i=0,x=+n; while (x>=1024 && i${safe(txt)}`; +} +export function rowHTML(cols, attrs='') { return `${cols.map(c => `${c ?? '—'}`).join('')}`; } +export function setRows(tbody, rows) { tbody.innerHTML = rows.length ? rows.join('') : rowHTML(['—']); } +export function fmtSeconds(s) { + if (s == null) return '—'; s = Math.floor(s); + const d = Math.floor(s/86400); s%=86400; + const h = Math.floor(s/3600); s%=3600; + const m = Math.floor(s/60); s%=60; + const parts = [h.toString().padStart(2,'0'), m.toString().padStart(2,'0'), s.toString().padStart(2,'0')].join(':'); + return d>0 ? `${d}d ${parts}` : parts; +} +export function parseNetConf(val) { + const out = {}; if (!val) return out; + val.split(',').forEach(kv => { const [k,v] = kv.split('='); if (k && v !== undefined) out[k.trim()] = v.trim(); }); + return out; +} +export function parseVmNetworks(config) { + const nets = []; + for (const [k, v] of Object.entries(config || {})) { + const m = k.match(/^net(\d+)$/); if (m) nets.push({ idx:+m[1], raw:v, ...parseNetConf(v) }); + } + nets.sort((a,b)=>a.idx-b.idx); return nets; +} +export function kvGrid(obj, keys, titleMap={}) { + return `
${ + keys.map(k => `
+
${titleMap[k] || k}
${safe(obj[k])}
+
`).join('')}
`; +} +// prefer first non-empty +export function pick(...vals) { for (const v of vals) { if (v !== undefined && v !== null && v !== '') return v; } return ''; } + +// toast +export function showToast(title, body, variant) { + const cont = document.getElementById('toast-container'); if (!cont) return; + const vcls = { success:'text-bg-success', info:'text-bg-info', warning:'text-bg-warning', danger:'text-bg-danger', secondary:'text-bg-secondary' }[variant||'secondary']; + const el = document.createElement('div'); + el.className = 'toast align-items-center ' + vcls; + el.setAttribute('role','alert'); el.setAttribute('aria-live','assertive'); el.setAttribute('aria-atomic','true'); + el.innerHTML = `
${title||''}${title?': ':''}${body||''}
+
`; + cont.appendChild(el); const t = new bootstrap.Toast(el, { delay: 5000 }); t.show(); el.addEventListener('hidden.bs.toast', () => el.remove()); +} diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..cc3e376 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,87 @@ +import { $, safe } from './helpers.js'; +import { api } from './api.js'; +import { refs, setHealth, renderClusterCards, renderUnits, renderReplicationTable, renderHAResources, renderNonHA, renderNodesTable } from './tables.js'; +import { renderVMAdmin } from './admin.js'; + +// ------ actions ------ +async function callAction(act) { + const node = refs.nodeInput.value || ''; + const d = await api.action(act, node); + alert(d.ok ? 'OK' : ('ERROR: ' + (d.error || 'unknown'))); +} +$('#btnEnable').onclick = () => callAction('enable'); +$('#btnDisable').onclick = () => callAction('disable'); +$('#btnToggleAll').onclick = () => { + document.querySelectorAll('.accordion-collapse').forEach(el => { + const bs = bootstrap.Collapse.getOrCreateInstance(el, { toggle: false }); + el.classList.contains('show') ? bs.hide() : bs.show(); + }); +}; + +// ------ refresh control ------ +let REF_TIMER = null; +let ac = null; // AbortController dla równoległych fetchy + +async function doRefresh() { + try { + if (ac) ac.abort(); + ac = new AbortController(); + const node = refs.nodeInput.value || ''; + // Minimalny szybki zestaw danych — równolegle: + const [cluster, nodes, units, repl] = await Promise.allSettled([ + api.clusterBrief(), api.nodesSummary(), api.units(node), api.replicationAll() + ]); + + // render + const vq = (cluster.value && cluster.value.votequorum) || {}; + const unitsMap = (units.value && units.value.units) || {}; + const allUnits = Object.values(unitsMap).every(v => v === 'active'); + setHealth((vq.quorate === 'yes') && allUnits, vq, allUnits); + + const gl = document.getElementById('global-loading'); if (gl) gl.remove(); + + refs.qSummary.textContent = `Quorate: ${safe(vq.quorate)} | members: ${safe(vq.members)} | expected: ${safe(vq.expected)} | total: ${safe(vq.total)} | quorum: ${safe(vq.quorum)}`; + renderClusterCards((cluster.value && cluster.value.cluster_status) || []); + renderUnits(unitsMap); + renderReplicationTable((repl.value || {jobs:[]})); + + renderHAResources((cluster.value && cluster.value.ha_resources) || []); + renderNodesTable((nodes.value && nodes.value.nodes) || []); + + refs.pvecmPre.textContent = safe(cluster.value && cluster.value.pvecm); + refs.cfgtoolPre.textContent = safe(cluster.value && cluster.value.cfgtool); + refs.footer.textContent = `node_arg=${safe(node)} | host=${safe(cluster.value && cluster.value.hostname)} | ts=${new Date(((cluster.value && cluster.value.ts) || 0) * 1000).toLocaleString()}`; + + // pierwszy raz: dociągnij Non-HA + VM Admin w idle + if (!doRefresh.didNonHA) { requestIdleCallback(() => renderNonHA().catch(console.error)); doRefresh.didNonHA = true; } + if (!doRefresh.didAdmin) { requestIdleCallback(() => renderVMAdmin().catch(console.error)); doRefresh.didAdmin = true; } + + } catch (e) { + console.error(e); + } +} +$('#btnRefresh').onclick = doRefresh; + +$('#btnAuto').onclick = () => { + if (REF_TIMER) { + clearInterval(REF_TIMER); REF_TIMER = null; + $('#btnAuto').textContent = 'OFF'; + $('#btnAuto').classList.remove('btn-success'); $('#btnAuto').classList.add('btn-outline-success'); + $('#selInterval').disabled = true; + } else { + const iv = parseInt($('#selInterval').value || '30000', 10); + REF_TIMER = setInterval(doRefresh, iv); + $('#btnAuto').textContent = 'ON'; + $('#btnAuto').classList.remove('btn-outline-success'); $('#btnAuto').classList.add('btn-success'); + $('#selInterval').disabled = false; + } +}; +$('#selInterval').onchange = () => { + if (REF_TIMER) { + clearInterval(REF_TIMER); + REF_TIMER = setInterval(doRefresh, parseInt($('#selInterval').value || '30000', 10)); + } +}; + +// initial one-shot load +doRefresh().catch(console.error); diff --git a/static/js/nodeDetail.js b/static/js/nodeDetail.js new file mode 100644 index 0000000..bf8f5fa --- /dev/null +++ b/static/js/nodeDetail.js @@ -0,0 +1,107 @@ +import { safe, ensureArr, badge, rowHTML, humanBytes, kvGrid, fmtSeconds, pick } from './helpers.js'; + +export function renderNodeDetailCard(d) { + const st = d.status || {}; + const ver = d.version || {}; + const tm = d.time || {}; + const netcfg = ensureArr(d.network_cfg); + const disks = ensureArr(d.disks); + const subscription = d.subscription || {}; + + // online detect + const isOn = /online|running/i.test(st.status || '') || + /online/i.test(st.hastate || '') || (st.uptime > 0) || + (st.cpu != null && st.maxcpu != null) || (st.memory && st.memory.total > 0); + const statusTxt = isOn ? 'online' : (st.status || 'offline'); + const sB = isOn ? badge(statusTxt, 'ok') : badge(statusTxt, 'err'); + + const mem = st.memory || {}; + const root = st.rootfs || {}; + const load = Array.isArray(st.loadavg) ? st.loadavg.join(' ') : (st.loadavg || ''); + + const cpuinfo = st.cpuinfo || {}; + const boot = st['boot-info'] || st.boot_info || {}; + const curKernel = st['current-kernel'] || st.current_kernel || {}; + const ramStr = (mem.used != null && mem.available != null && mem.total != null) + ? `${humanBytes(mem.used)} used / ${humanBytes(mem.available)} free / ${humanBytes(mem.total)} total` + : (mem.total != null ? humanBytes(mem.total) : '—'); + + const tech = { + 'PVE version': pick(st.pveversion, ver.pvemanager, ver['pve-manager']), + 'Kernel': pick(st.kversion, curKernel.release, ver.kernel, ver.release), + 'CPU model': pick(cpuinfo.model, st['cpu-model'], ver['cpu-model'], ver.cpu), + 'Architecture': pick(curKernel.machine, ver.arch, st.architecture, st.arch), + 'RAM': ramStr, + 'Boot mode': pick(boot.mode) ? String(boot.mode).toUpperCase() : '—', + 'Secure Boot': (boot.secureboot === 1 || boot.secureboot === '1') ? 'enabled' : + (boot.secureboot === 0 || boot.secureboot === '0') ? 'disabled' : '—' + }; + + const top = ` +
+
${safe(d.node)}
+
${sB}
+
+
CPU: ${safe(((st.cpu??null)!==null)?(st.cpu*100).toFixed(1)+'%':'—')}
+
Load: ${safe(load)}
+
Uptime: ${fmtSeconds(st.uptime)}
+
`; + + const memCard = ` +
+
+
Memory
+
${(mem.used != null && mem.total != null) ? `${humanBytes(mem.used)} / ${humanBytes(mem.total)} (${((mem.used/mem.total)*100).toFixed(1)}%)` : '—'}
+
+
+
RootFS
+
${(root.used != null && root.total != null) ? `${humanBytes(root.used)} / ${humanBytes(root.total)} (${((root.used/root.total)*100).toFixed(1)}%)` : '—'}
+
+
+
Kernel / QEMU
+
${safe(tech['Kernel'])} / ${safe(pick(ver.qemu, ver['running-qemu']))}
+
+
+
Time
+
${safe(tm.localtime)} ${tm.timezone ? `(${tm.timezone})` : ''}
+
+
`; + + const sysDetails = kvGrid(tech, Object.keys(tech), { + 'PVE version': 'PVE version','Kernel':'Kernel version','CPU model':'CPU model', + 'Architecture':'Arch','RAM':'RAM (used/free/total)','Boot mode':'Boot mode','Secure Boot':'Secure Boot' + }); + + const netRows = ensureArr(netcfg).map(n => rowHTML([safe(n.iface||n.ifname), safe(n.type), safe(n.method||n.autostart), safe(n.bridge_ports||n.address||'—'), safe(n.cidr||n.netmask||'—'), safe(n.comments||'')])); + const netCfgTable = `
+ + ${netRows.length ? netRows.join('') : rowHTML(['—','—','—','—','—','—'])}
IFTypeMethodPorts/AddressNetmask/CIDRComment
`; + + const diskRows = ensureArr(disks).map(dv => rowHTML([safe(dv.devpath||dv.kname||dv.dev), safe(dv.model), safe(dv.size?humanBytes(dv.size):'—'), safe(dv.health||dv.wearout||'—'), safe(dv.serial||'—')])); + const diskTable = `
+ + ${diskRows.length ? diskRows.join('') : rowHTML(['—','—','—','—','—'])}
DeviceModelSizeHealthSerial
`; + + // Subscription: ukryj, gdy notfound/No subscription key + const subTxt = (subscription.message||'') + ' ' + (subscription.status||''); + const hideSub = /notfound/i.test(subTxt) || /no subscription key/i.test(subTxt); + const subBox = hideSub ? '' : ` +
Subscription
+
+
Status: ${badge(safe(subscription.status||'unknown'), /active|valid/i.test(subscription.status||'') ? 'ok':'warn')}
+ ${subscription.productname ? `
Product: ${safe(subscription.productname)}
` : ''} + ${subscription.message ? `
${safe(subscription.message)}
` : ''} +
+
`; + + const rawId = `raw-node-${safe(d.node)}`; + const rawBtn = ``; + const rawBox = `
${JSON.stringify(d, null, 2)}
`; + + return `${top}${memCard} +
System details
${sysDetails}
+
Network (config)
${netCfgTable}
+
Disks
${diskTable}
+ ${subBox} + ${rawBtn}${rawBox}`; +} diff --git a/static/js/tables.js b/static/js/tables.js new file mode 100644 index 0000000..5c6e5bc --- /dev/null +++ b/static/js/tables.js @@ -0,0 +1,191 @@ +import { $, safe, ensureArr, badge, rowHTML, setRows, pct, humanBytes, fmtSeconds, kvGrid, showToast } from './helpers.js'; +import { api } from './api.js'; +import { renderVmDetailCard } from './vmDetail.js'; +import { renderNodeDetailCard } from './nodeDetail.js'; + +// DOM refs +export const refs = { + nodeInput: $('#node'), + healthDot: $('#healthDot'), healthTitle: $('#healthTitle'), healthSub: $('#healthSub'), + qSummary: $('#q-summary'), qCardsWrap: $('#q-cards'), unitsBox: $('#units'), replBox: $('#repl'), + tblHaRes: $('#ha-res'), tblHaStatus: $('#ha-status'), // (tblHaStatus nieużywany, sekcja wycięta w HTML) + tblNodes: $('#nodes'), tblNonHA: $('#nonha'), + pvecmPre: $('#pvecm'), cfgtoolPre: $('#cfgtool'), footer: $('#footer') +}; + +// Health +export function setHealth(ok, vq, unitsActive) { + refs.healthDot.classList.toggle('ok', !!ok); + refs.healthDot.classList.toggle('bad', !ok); + refs.healthTitle.textContent = ok ? 'HA: OK' : 'HA: PROBLEM'; + refs.healthSub.textContent = `Quorate=${String(vq.quorate)} | units=${unitsActive ? 'active' : 'inactive'} | members=${safe(vq.members)} | quorum=${safe(vq.quorum)}/${safe(vq.expected)}`; +} + +// Cluster cards +export function renderClusterCards(arr) { + const a = ensureArr(arr); refs.qCardsWrap.innerHTML = ''; + if (!a.length) { refs.qCardsWrap.innerHTML = badge('No data','dark'); return; } + const cluster = a.find(x => x.type === 'cluster') || {}; + const qB = cluster.quorate ? badge('Quorate: yes','ok') : badge('Quorate: no','err'); + refs.qCardsWrap.insertAdjacentHTML('beforeend', ` +
+
+
${safe(cluster.name)}
+
id: ${safe(cluster.id)}
+
${qB}
+
nodes: ${safe(cluster.nodes)}
+
version: ${safe(cluster.version)}
+
+
`); + const nodes = a.filter(x => x.type === 'node'); + const rows = nodes.map(n => { + const online = n.online ? badge('online','ok') : badge('offline','err'); + const local = n.local ? ' ' + badge('local','info') : ''; + return rowHTML([safe(n.name), online + local, safe(n.ip), safe(n.nodeid), safe(n.level)]); + }); + refs.qCardsWrap.insertAdjacentHTML('beforeend', ` +
+ + + ${rows.join('')} +
NodeStatusIPNodeIDLevel
+
`); +} + +export function renderUnits(units) { + refs.unitsBox.innerHTML = ''; + if (!units || !Object.keys(units).length) { refs.unitsBox.innerHTML = badge('No data','dark'); return; } + const map = { active:'ok', inactive:'err', failed:'err', activating:'warn' }; + Object.entries(units).forEach(([k, v]) => refs.unitsBox.insertAdjacentHTML('beforeend', `${badge(k, map[v] || 'dark')}`)); +} + +// Replication — ALL NODES +export function renderReplicationTable(repAll) { + const arr = ensureArr(repAll.jobs); + if (!arr.length) { refs.replBox && (refs.replBox.innerHTML = 'No replication jobs'); return; } + const rows = arr.map(x => { + const en = /^yes$/i.test(x.enabled) ? badge('Yes','ok') : badge('No','err'); + const st = /^ok$/i.test(x.state) ? badge(x.state,'ok') : badge(x.state,'err'); + const fc = x.fail > 0 ? badge(String(x.fail),'err') : badge(String(x.fail),'ok'); + return rowHTML([safe(x.node), safe(x.job), en, safe(x.target), safe(x.last), safe(x.next), safe(x.dur), fc, st]); + }); + refs.replBox.innerHTML = `
+ + ${rows.join('')}
NodeJobIDEnabledTargetLastSyncNextSyncDurationFailCountState
`; +} + +// HA resources (expand) +export function renderHAResources(list) { + const tbody = refs.tblHaRes.querySelector('tbody'); + const arr = ensureArr(list); const rows = []; + arr.forEach(x => { + const st = x.state || '—'; + const stB = /start/i.test(st) ? badge(st,'ok') : (/stop/i.test(st) ? badge(st,'err') : badge(st,'dark')); + const sid = safe(x.sid); + rows.push(rowHTML([``, sid, stB, safe(x.node), safe(x.group), safe(x.flags || x.comment)], `class="expandable vm-row" data-sid="${sid}"`)); + rows.push(`
`); + }); + setRows(tbody, rows.length ? rows : [rowHTML(['—','—','—','—','—','—'])]); + + Array.from(refs.tblHaRes.querySelectorAll('tr.vm-row')).forEach((tr, i) => { + tr.onclick = async () => { + const detailRow = refs.tblHaRes.querySelectorAll('tr.vm-detail')[i]; + const content = detailRow.querySelector('.vm-json'); + const spin = detailRow.querySelector('.spinner-border'); + const open = detailRow.classList.contains('d-none'); + // collapse all + refs.tblHaRes.querySelectorAll('tr.vm-detail').forEach(r => r.classList.add('d-none')); + refs.tblHaRes.querySelectorAll('tr.vm-row').forEach(r => r.classList.remove('expanded')); + if (open) { + detailRow.classList.remove('d-none'); tr.classList.add('expanded'); + spin.classList.remove('d-none'); + const sid = tr.getAttribute('data-sid'); + try { const d = await api.vmDetail(sid); content.innerHTML = renderVmDetailCard(d); } + catch (e) { content.textContent = 'ERROR: ' + e; } + spin.classList.add('d-none'); + } + }; + }); +} + +// Non-HA VM/CT (expand) +export async function renderNonHA() { + const r = await api.listNonHA(); + const arr = ensureArr(r.nonha); + const tbody = refs.tblNonHA.querySelector('tbody'); + if (!arr.length) { setRows(tbody, [rowHTML(['','No non-HA VMs/CTs','','','',''])]); return; } + const rows = []; + arr.forEach(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'); + rows.push(rowHTML([``, sid, type, name, node, st], `class="expandable vm-row" data-sid="${sid}"`)); + rows.push(`
`); + }); + setRows(tbody, rows); + Array.from(tbody.querySelectorAll('tr.vm-row')).forEach((tr, i) => { + tr.onclick = async () => { + const detailRow = tbody.querySelectorAll('tr.vm-detail')[i]; + const content = detailRow.querySelector('.vm-json'); + const spin = detailRow.querySelector('.spinner-border'); + const open = detailRow.classList.contains('d-none'); + tbody.querySelectorAll('tr.vm-detail').forEach(r => r.classList.add('d-none')); + tbody.querySelectorAll('tr.vm-row').forEach(r => r.classList.remove('expanded')); + if (open) { + detailRow.classList.remove('d-none'); tr.classList.add('expanded'); + spin.classList.remove('d-none'); + const sid = tr.getAttribute('data-sid'); + try { const d = await api.vmDetail(sid); content.innerHTML = renderVmDetailCard(d); } + catch (e) { content.textContent = 'ERROR: ' + e; } + spin.classList.add('d-none'); + } + }; + }); +} + +// Nodes table (expand) +export function renderNodesTable(nodes) { + const tbody = refs.tblNodes.querySelector('tbody'); + const nrows = ensureArr(nodes).map(n => { + const isOn = /online|running/i.test(n.status || '') || + /online/i.test(n.hastate || '') || (n.uptime > 0) || + (n.cpu != null && n.maxcpu != null) || (n.mem != null && n.maxmem != null); + const statusTxt = isOn ? 'online' : (n.status || 'offline'); + const sB = isOn ? badge(statusTxt,'ok') : badge(statusTxt,'err'); + + const mem = (n.mem != null && n.maxmem) ? `${humanBytes(n.mem)} / ${humanBytes(n.maxmem)} (${pct(n.mem / n.maxmem)})` : '—'; + const rfs = (n.rootfs != null && n.maxrootfs) ? `${humanBytes(n.rootfs)} / ${humanBytes(n.maxrootfs)} (${pct(n.rootfs / n.maxrootfs)})` : '—'; + const load = (n.loadavg != null) ? String(n.loadavg) : '—'; + const cpu = (n.cpu != null) ? pct(n.cpu) : '—'; + + const main = ` + ${safe(n.node)} + ${sB}${cpu}${load}${mem}${rfs}${fmtSeconds(n.uptime)} + `; + + const detail = ` +
+ `; + + return main + detail; + }); + setRows(tbody, nrows); + + Array.from(tbody.querySelectorAll('tr.node-row')).forEach((tr, i) => { + tr.onclick = async () => { + const detailRow = tbody.querySelectorAll('tr.node-detail')[i]; + const content = detailRow.querySelector('.node-json'); + const spin = detailRow.querySelector('.spinner-border'); + const open = detailRow.classList.contains('d-none'); + tbody.querySelectorAll('tr.node-detail').forEach(r => r.classList.add('d-none')); + tbody.querySelectorAll('tr.node-row').forEach(r => r.classList.remove('expanded')); + if (open) { + detailRow.classList.remove('d-none'); tr.classList.add('expanded'); + spin.classList.remove('d-none'); + const name = tr.getAttribute('data-node'); + try { const d = await api.nodeDetail(name); content.innerHTML = renderNodeDetailCard(d); } + catch (e) { content.textContent = 'ERROR: ' + e; } + spin.classList.add('d-none'); + } + }; + }); +} diff --git a/static/js/vmDetail.js b/static/js/vmDetail.js new file mode 100644 index 0000000..79eb3b2 --- /dev/null +++ b/static/js/vmDetail.js @@ -0,0 +1,136 @@ +import { safe, ensureArr, badge, rowHTML, humanBytes, kvGrid, fmtSeconds, parseVmNetworks } from './helpers.js'; + +export function renderVmDetailCard(d) { + // --- dokładnie ten sam kod, który miałeś — przeniesiony bez zmian --- + // (skrócone dla czytelności — wklejam pełną wersję z Twojego pliku) + const meta = d.meta || {}; + const cur = d.current || {}; + const cfg = d.config || {}; + const ag = d.agent || {}; + const agInfo = ag.info || null; + const agOS = ag.osinfo && ag.osinfo.result ? ag.osinfo.result : null; + const agIfs = ag.ifaces && ag.ifaces.result ? ag.ifaces.result : null; + + const statusBadge = /running|online|started/i.test(meta.status || cur.status || '') + ? badge(meta.status || cur.status || 'running', 'ok') + : badge(meta.status || cur.status || 'stopped', 'err'); + + const maxmem = cur.maxmem ?? (cfg.memory ? Number(cfg.memory) * 1024 * 1024 : null); + const used = cur.mem ?? null; + const free = (maxmem != null && used != null) ? Math.max(0, maxmem - used) : null; + const balloonEnabled = (cfg.balloon !== undefined) ? (Number(cfg.balloon) !== 0) : (cur.balloon !== undefined && Number(cur.balloon) !== 0); + const binfo = cur.ballooninfo || null; + + let guestName = agOS && (agOS.name || agOS.pretty_name) || (agInfo && agInfo.version) || ''; + let guestIPs = []; + if (Array.isArray(agIfs)) { + agIfs.forEach(i => { + (i['ip-addresses'] || []).forEach(ip => { const a = ip['ip-address']; if (a && !a.startsWith('fe80')) guestIPs.push(a); }); + }); + } + + const bstat = cur.blockstat || {}; + const bRows = Object.keys(bstat).sort().map(dev => { + const s = bstat[dev] || {}; + return rowHTML([dev, humanBytes(s.rd_bytes||0), String(s.rd_operations||0), + humanBytes(s.wr_bytes||0), String(s.wr_operations||0), String(s.flush_operations||0), humanBytes(s.wr_highest_offset||0)]); + }); + + const ha = cur.ha || {}; + const haBadge = ha.state ? (/started/i.test(ha.state) ? badge(ha.state,'ok') : badge(ha.state,'warn')) : badge('—','dark'); + + const sysCards = { + 'QMP status': cur.qmpstatus, 'QEMU': cur['running-qemu'], 'Machine': cur['running-machine'], 'PID': cur.pid, + 'Pressure CPU (some/full)': `${String(cur.pressurecpusome||'—')}/${String(cur.pressurecpufull||'—')}`, + 'Pressure IO (some/full)': `${String(cur.pressureiosome||'—')}/${String(cur.pressureiofull||'—')}`, + 'Pressure MEM (some/full)': `${String(cur.pressurememorysome||'—')}/${String(cur.pressurememoryfull||'—')}` + }; + + const nets = parseVmNetworks(cfg); + const netRows = nets.map(n => { + const br = n.bridge || n.br || '—'; + const mdl = n.model || n.type || (n.raw?.split(',')[0]?.split('=')[0]) || 'virtio'; + const mac = n.hwaddr || n.mac || (n.raw?.match(/([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}/)?.[0] || '—'); + const vlan = n.tag || n.vlan || '—'; + const fw = (n.firewall === '1') ? badge('on','warn') : badge('off','dark'); + return rowHTML([`net${n.idx}`, mdl, br, vlan, mac, fw]); + }); + + const netTable = ` +
+ + + ${netRows.length ? netRows.join('') : rowHTML(['—','—','—','—','—','—'])} +
IFModelBridgeVLANMACFW
+
`; + + const agentSummary = (agInfo || agOS || guestIPs.length) + ? `
+ ${agOS ? `
Guest OS: ${safe(guestName)}
` : ''} + ${guestIPs.length ? `
Guest IPs: ${guestIPs.map(ip => badge(ip,'info')).join(' ')}
` : ''} + ${agInfo ? `
Agent: ${badge('present','ok')}
` : `
Agent: ${badge('not available','err')}
`} +
` + : '
No guest agent data
'; + + const cfgFacts = { + 'BIOS': cfg.bios, 'UEFI/EFI disk': cfg.efidisk0 ? 'yes' : 'no', + 'CPU type': cfg.cpu, 'Sockets': cfg.sockets, 'Cores': cfg.cores, 'NUMA': cfg.numa, + 'On boot': cfg.onboot ? 'yes' : 'no', 'OS type': cfg.ostype, 'SCSI hw': cfg.scsihw + }; + + const rawId = `raw-${d.type}-${d.vmid}`; + const rawBtn = ``; + const rawBox = `
+ +
+
${JSON.stringify(cur,null,2)}
+
${JSON.stringify(cfg,null,2)}
+
${JSON.stringify(d.agent||{},null,2)}
+
+
`; + + return ` +
+
${safe(meta.name || cfg.name || d.sid)}
+
${(d.type || '').toUpperCase()} / VMID ${safe(d.vmid)} @ ${safe(d.node)}
+
${statusBadge}
+ ${meta.hastate ? `
HA: ${badge(meta.hastate, /started/i.test(meta.hastate) ? 'ok' : 'warn')}
` : ''} + ${ha.state ? `
HA runtime: ${haBadge}
` : ''} +
+ +
+
+
CPU
${cur.cpu !== undefined ? (cur.cpu * 100).toFixed(1) + '%' : '—'}
+
vCPUs: ${safe(cur.cpus)}
+
+
+
Memory (used/free/total)
+
${(used != null && maxmem != null) ? `${humanBytes(used)} / ${humanBytes(free)} / ${humanBytes(maxmem)}` : '—'}
+
+
+
Disk (used/total)
+
${(cur.disk != null && cur.maxdisk != null) ? `${humanBytes(cur.disk)} / ${humanBytes(cur.maxdisk)}` : '—'}
+
R: ${humanBytes(cur.diskread||0)} | W: ${humanBytes(cur.diskwrite||0)}
+
+
+
Uptime
${fmtSeconds(cur.uptime)}
+
+
+ +
Network (config)
${netTable}
+
Disks (block statistics)
+
+ + ${Object.keys(bstat).length ? bRows.join('') : rowHTML(['—','—','—','—','—','—','—'])}
DeviceRead bytesRead opsWrite bytesWrite opsFlush opsHighest offset
+
+ +
System / QEMU
${kvGrid(sysCards, Object.keys(sysCards))}
+
Config facts
${kvGrid(cfgFacts, Object.keys(cfgFacts))}
+
${agentSummary}
+ ${rawBtn}${rawBox} + `; +} diff --git a/static/main.js b/static/main.js deleted file mode 100644 index c6204a7..0000000 --- a/static/main.js +++ /dev/null @@ -1,900 +0,0 @@ -// ------ 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 : []; } -function pct(p) { if (p == null) return '—'; return (p * 100).toFixed(1) + '%'; } -function humanBytes(n) { if (n == null) return '—'; const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; let i = 0, x = +n; while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; } return x.toFixed(1) + ' ' + u[i]; } -function badge(txt, kind) { - const cls = { - ok: 'bg-success-subtle text-success-emphasis', warn: 'bg-warning-subtle text-warning-emphasis', - err: 'bg-danger-subtle text-danger-emphasis', info: 'bg-info-subtle text-info-emphasis', - dark: 'bg-secondary-subtle text-secondary-emphasis' - }[kind || 'dark']; - return `${safe(txt)}`; -} -function rowHTML(cols, attrs = '') { return `${cols.map(c => `${c ?? '—'}`).join('')}`; } -function setRows(tbody, rows) { tbody.innerHTML = rows.length ? rows.join('') : rowHTML(['—']); } -function fmtSeconds(s) { - if (s == null) return '—'; - s = Math.floor(s); - const d = Math.floor(s / 86400); s %= 86400; - const h = Math.floor(s / 3600); s %= 3600; - const m = Math.floor(s / 60); s %= 60; - const parts = [h.toString().padStart(2, '0'), m.toString().padStart(2, '0'), s.toString().padStart(2, '0')].join(':'); - return d > 0 ? `${d}d ${parts}` : parts; -} -function parseNetConf(val) { - const out = {}; if (!val) return out; - val.split(',').forEach(kv => { - const [k, v] = kv.split('='); - if (k && v !== undefined) out[k.trim()] = v.trim(); - }); - return out; -} -function parseVmNetworks(config) { - const nets = []; - for (const [k, v] of Object.entries(config || {})) { - const m = k.match(/^net(\d+)$/); - if (m) { nets.push({ idx: +m[1], raw: v, ...parseNetConf(v) }); } - } - nets.sort((a, b) => a.idx - b.idx); - return nets; -} -function kvGrid(obj, keys, titleMap = {}) { - return `
- ${keys.map(k => ` -
-
-
${titleMap[k] || k}
-
${safe(obj[k])}
-
-
`).join('')} -
`; -} -// prefer first non-empty -function pick(...vals) { for (const v of vals) { if (v !== undefined && v !== null && v !== '') return v; } return ''; } - -// ------ DOM refs ------ -const nodeInput = $('#node'), btnEnable = $('#btnEnable'), btnDisable = $('#btnDisable'), btnToggleAll = $('#btnToggleAll'); -const btnRefresh = $('#btnRefresh'), btnAuto = $('#btnAuto'), selInterval = $('#selInterval'); -const healthDot = $('#healthDot'), healthTitle = $('#healthTitle'), healthSub = $('#healthSub'); -const qSummary = $('#q-summary'), qCardsWrap = $('#q-cards'), unitsBox = $('#units'), replBox = $('#repl'); -const tblHaRes = $('#ha-res'), tblHaStatus = $('#ha-status'), tblNodes = $('#nodes'), tblNonHA = $('#nonha'); -const pvecmPre = $('#pvecm'), cfgtoolPre = $('#cfgtool'), footer = $('#footer'); - -// ------ actions ------ -async function callAction(act) { - const node = nodeInput.value || ''; - const r = await fetch('/api/' + act, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ node }) }); - const d = await r.json(); alert(d.ok ? 'OK' : ('ERROR: ' + (d.error || 'unknown'))); -} -btnEnable.onclick = () => callAction('enable'); -btnDisable.onclick = () => callAction('disable'); -btnToggleAll.onclick = () => { - document.querySelectorAll('.accordion-collapse').forEach(el => { - const bs = bootstrap.Collapse.getOrCreateInstance(el, { toggle: false }); - el.classList.contains('show') ? bs.hide() : bs.show(); - }); -}; - -// ------ refresh control ------ -let REF_TIMER = null; -async function fetchSnapshot() { - const r = await fetch('/api/info?node=' + encodeURIComponent(nodeInput.value || '')); - return await r.json(); -} -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 = () => { - if (REF_TIMER) { - clearInterval(REF_TIMER); REF_TIMER = null; - btnAuto.textContent = 'OFF'; - btnAuto.classList.remove('btn-success'); btnAuto.classList.add('btn-outline-success'); - selInterval.disabled = true; - } else { - const iv = parseInt(selInterval.value || '30000', 10); - REF_TIMER = setInterval(doRefresh, iv); - btnAuto.textContent = 'ON'; - btnAuto.classList.remove('btn-outline-success'); btnAuto.classList.add('btn-success'); - selInterval.disabled = false; - } -}; -selInterval.onchange = () => { - if (REF_TIMER) { - clearInterval(REF_TIMER); - REF_TIMER = setInterval(doRefresh, parseInt(selInterval.value || '30000', 10)); - } -}; - -// ------ VM detail API ------ -async function fetchVmDetail(sid) { - const r = await fetch('/api/vm?sid=' + encodeURIComponent(sid)); - return await r.json(); -} -// ------ Node detail API ------ -async function fetchNodeDetail(name) { - const r = await fetch('/api/node?name=' + encodeURIComponent(name)); - return await r.json(); -} - -// ------ VM detail card ------ -function renderVmDetailCard(d) { - const meta = d.meta || {}; - const cur = d.current || {}; - const cfg = d.config || {}; - const ag = d.agent || {}; - const agInfo = ag.info || null; - const agOS = ag.osinfo && ag.osinfo.result ? ag.osinfo.result : null; - const agIfs = ag.ifaces && ag.ifaces.result ? ag.ifaces.result : null; - - const statusBadge = /running|online|started/i.test(meta.status || cur.status || '') - ? badge(meta.status || cur.status || 'running', 'ok') - : badge(meta.status || cur.status || 'stopped', 'err'); - - const maxmem = cur.maxmem ?? (cfg.memory ? Number(cfg.memory) * 1024 * 1024 : null); - const used = cur.mem ?? null; - const free = (maxmem != null && used != null) ? Math.max(0, maxmem - used) : null; - const balloonEnabled = (cfg.balloon !== undefined) ? (Number(cfg.balloon) !== 0) : (cur.balloon !== undefined && Number(cur.balloon) !== 0); - const binfo = cur.ballooninfo || null; - - let guestName = agOS && (agOS.name || agOS.pretty_name) || (agInfo && agInfo.version) || ''; - let guestIPs = []; - if (Array.isArray(agIfs)) { - agIfs.forEach(i => { - (i['ip-addresses'] || []).forEach(ip => { - const a = ip['ip-address']; if (a && !a.startsWith('fe80')) guestIPs.push(a); - }); - }); - } - - const bstat = cur.blockstat || {}; - const bRows = Object.keys(bstat).sort().map(dev => { - const s = bstat[dev] || {}; - return rowHTML([ - dev, humanBytes(s.rd_bytes || 0), safe(s.rd_operations || 0), - humanBytes(s.wr_bytes || 0), safe(s.wr_operations || 0), - safe(s.flush_operations || 0), humanBytes(s.wr_highest_offset || 0) - ]); - }); - - const ha = cur.ha || {}; - const haBadge = ha.state ? (/started/i.test(ha.state) ? badge(ha.state, 'ok') : badge(ha.state, 'warn')) : badge('—', 'dark'); - - const sysCards = { - 'QMP status': cur.qmpstatus, - 'QEMU': cur['running-qemu'], - 'Machine': cur['running-machine'], - 'PID': cur.pid, - 'Pressure CPU (some/full)': `${safe(cur.pressurecpusome)}/${safe(cur.pressurecpufull)}`, - 'Pressure IO (some/full)': `${safe(cur.pressureiosome)}/${safe(cur.pressureiofull)}`, - 'Pressure MEM (some/full)': `${safe(cur.pressurememorysome)}/${safe(cur.pressurememoryfull)}` - }; - - const nets = parseVmNetworks(cfg); - const netRows = nets.map(n => { - const br = n.bridge || n.br || '—'; - const mdl = n.model || n.type || (n.raw?.split(',')[0]?.split('=')[0]) || 'virtio'; - const mac = n.hwaddr || n.mac || (n.raw?.match(/([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}/)?.[0] || '—'); - const vlan = n.tag || n.vlan || '—'; - const fw = (n.firewall === '1') ? badge('on', 'warn') : badge('off', 'dark'); - return rowHTML([`net${n.idx}`, mdl, br, vlan, mac, fw]); - }); - const netTable = ` -
- - - ${netRows.length ? netRows.join('') : rowHTML(['—', '—', '—', '—', '—', '—'])} -
IFModelBridgeVLANMACFW
-
`; - - const agentSummary = (agInfo || agOS || guestIPs.length) - ? `
- ${agOS ? `
Guest OS: ${safe(guestName)}
` : ''} - ${guestIPs.length ? `
Guest IPs: ${guestIPs.map(ip => badge(ip, 'info')).join(' ')}
` : ''} - ${agInfo ? `
Agent: ${badge('present', 'ok')}
` : `
Agent: ${badge('not available', 'err')}
`} -
` - : '
No guest agent data
'; - - const cfgFacts = { - 'BIOS': cfg.bios, 'UEFI/EFI disk': cfg.efidisk0 ? 'yes' : 'no', - 'CPU type': cfg.cpu, 'Sockets': cfg.sockets, 'Cores': cfg.cores, 'NUMA': cfg.numa, - 'On boot': cfg.onboot ? 'yes' : 'no', 'OS type': cfg.ostype, 'SCSI hw': cfg.scsihw - }; - - // Collapsible raw JSON - const rawId = `raw-${d.type}-${d.vmid}`; - const rawBtn = ` - `; - const rawBox = ` -
- -
-
${JSON.stringify(cur, null, 2)}
-
${JSON.stringify(cfg, null, 2)}
-
${JSON.stringify(ag, null, 2)}
-
-
`; - - return ` -
-
${safe(meta.name || cfg.name || d.sid)}
-
${(d.type || '').toUpperCase()} / VMID ${safe(d.vmid)} @ ${safe(d.node)}
-
-
${statusBadge}
- ${meta.hastate ? `
HA: ${badge(meta.hastate, /started/i.test(meta.hastate) ? 'ok' : 'warn')}
` : ''} - ${ha.state ? `
HA runtime: ${haBadge}
` : ''} -
- -
-
-
CPU
${cur.cpu !== undefined ? (cur.cpu * 100).toFixed(1) + '%' : '—'}
-
vCPUs: ${safe(cur.cpus ?? cfg.cores ?? cfg.sockets)}
-
-
-
Memory (used/free/total)
-
${(used != null && maxmem != null) - ? `${humanBytes(used)} / ${humanBytes(free)} / ${humanBytes(maxmem)}` - : '—'}
-
Ballooning: ${balloonEnabled ? badge('enabled', 'ok') : badge('disabled', 'err')}
- ${binfo ? `
Balloon actual: ${humanBytes(binfo.actual)} | guest free: ${humanBytes(binfo.free_mem)} | guest total: ${humanBytes(binfo.total_mem)}
` : ''} -
-
-
Disk (used/total)
-
${(cur.disk != null && cur.maxdisk != null) ? `${humanBytes(cur.disk)} / ${humanBytes(cur.maxdisk)}` : '—'}
-
R: ${humanBytes(cur.diskread || 0)} | W: ${humanBytes(cur.diskwrite || 0)}
-
-
-
Uptime
${fmtSeconds(cur.uptime)}
-
Tags: ${safe(cfg.tags || meta.tags || '—')}
-
-
- -
-
Network (config)
- ${netTable} -
- -
-
Disks (block statistics)
-
- - - ${Object.keys(bstat).length ? bRows.join('') : rowHTML(['—', '—', '—', '—', '—', '—', '—'])} -
DeviceRead bytesRead opsWrite bytesWrite opsFlush opsHighest offset
-
-
- -
-
System / QEMU
- ${kvGrid(sysCards, Object.keys(sysCards))} -
- -
Config facts
${kvGrid(cfgFacts, Object.keys(cfgFacts))}
- -
${agentSummary}
- - ${rawBtn} - ${rawBox} - `; -} - -// ------ Node detail card ------ -function renderNodeDetailCard(d) { - const st = d.status || {}; lastSt = st; - const ver = d.version || {}; - const tm = d.time || {}; - const netcfg = ensureArr(d.network_cfg); - const disks = ensureArr(d.disks); - const subscription = d.subscription || {}; // <-- JEDYNA deklaracja (zamiast podwójnego 'const sub') - - // robust online detection - const isOn = /online|running/i.test(st.status || '') || - /online/i.test(st.hastate || '') || - (st.uptime > 0) || - (st.cpu != null && st.maxcpu != null) || - (st.memory && st.memory.total > 0); - const statusTxt = isOn ? 'online' : (st.status || 'offline'); - const sB = isOn ? badge(statusTxt, 'ok') : badge(statusTxt, 'err'); - - const mem = st.memory || {}; - const root = st.rootfs || {}; - const load = Array.isArray(st.loadavg) ? st.loadavg.join(' ') : (st.loadavg || ''); - - // ---- SYSTEM DETAILS - const cpuinfo = st.cpuinfo || {}; - const boot = st['boot-info'] || st.boot_info || {}; - const curKernel = st['current-kernel'] || st.current_kernel || {}; - const ramStr = (mem.used != null && mem.available != null && mem.total != null) - ? `${humanBytes(mem.used)} used / ${humanBytes(mem.available)} free / ${humanBytes(mem.total)} total` - : (mem.total != null ? humanBytes(mem.total) : '—'); - - const tech = { - 'PVE version': pick(st.pveversion, ver.pvemanager, ver['pve-manager']), - 'Kernel': pick(st.kversion, curKernel.release, ver.kernel, ver.release), - 'CPU model': pick(cpuinfo.model, st['cpu-model'], ver['cpu-model'], ver.cpu), - 'Architecture': pick(curKernel.machine, ver.arch, st.architecture, st.arch), - 'RAM': ramStr, - 'Boot mode': pick(boot.mode) ? String(boot.mode).toUpperCase() : '—', - 'Secure Boot': (boot.secureboot === 1 || boot.secureboot === '1') ? 'enabled' : - (boot.secureboot === 0 || boot.secureboot === '0') ? 'disabled' : '—' - }; - - const top = ` -
-
${safe(d.node)}
-
-
${sB}
-
-
CPU: ${pct(st.cpu)}
-
Load: ${safe(load)}
-
Uptime: ${fmtSeconds(st.uptime)}
-
`; - - const memCard = ` -
-
-
Memory
-
${(mem.used != null && mem.total != null) ? `${humanBytes(mem.used)} / ${humanBytes(mem.total)} (${pct(mem.used / mem.total)})` : '—'}
-
-
-
RootFS
-
${(root.used != null && root.total != null) ? `${humanBytes(root.used)} / ${humanBytes(root.total)} (${pct(root.used / root.total)})` : '—'}
-
-
-
Kernel / QEMU
-
${safe(tech['Kernel'])} / ${safe(pick(ver.qemu, ver['running-qemu']))}
-
-
-
Time
-
${safe(tm.localtime)} ${tm.timezone ? `(${tm.timezone})` : ''}
-
-
`; - - const sysDetails = kvGrid(tech, Object.keys(tech), { - 'PVE version': 'PVE version', - 'Kernel': 'Kernel version', - 'CPU model': 'CPU model', - 'Architecture': 'Arch', - 'RAM': 'RAM (used/free/total)', - 'Boot mode': 'Boot mode', - 'Secure Boot': 'Secure Boot' - }); - - // Network config - const netRows = netcfg.map(n => { - return rowHTML([safe(n.iface || n.ifname), safe(n.type), safe(n.method || n.autostart), safe(n.bridge_ports || n.address || '—'), safe(n.cidr || n.netmask || '—'), safe(n.comments || '')]); - }); - const netCfgTable = ` -
- - - ${netRows.length ? netRows.join('') : rowHTML(['—', '—', '—', '—', '—', '—'])} -
IFTypeMethodPorts/AddressNetmask/CIDRComment
-
`; - - // Disks - const diskRows = disks.map(dv => rowHTML([safe(dv.devpath || dv.kname || dv.dev), safe(dv.model), safe(dv.size ? humanBytes(dv.size) : '—'), safe(dv.health || dv.wearout || '—'), safe(dv.serial || '—')])); - const diskTable = ` -
- - - ${diskRows.length ? diskRows.join('') : rowHTML(['—', '—', '—', '—', '—'])} -
DeviceModelSizeHealthSerial
-
`; - - // Subscription - const subBox = ` -
-
Status: ${badge(safe(subscription.status || 'unknown'), /active|valid/i.test(subscription.status || '') ? 'ok' : 'warn')}
- ${subscription.productname ? `
Product: ${safe(subscription.productname)}
` : ''} - ${subscription.message ? `
${safe(subscription.message)}
` : ''} -
`; - - // Collapsible raw JSON - const rawId = `raw-node-${safe(d.node)}`; - const rawBtn = ` - `; - const rawBox = ` -
-
${JSON.stringify(d, null, 2)}
-
`; - - return ` - ${top} - ${memCard} - -
-
System details
- ${sysDetails} -
- -
-
Network (config)
- ${netCfgTable} -
- -
-
Disks
- ${diskTable} -
- -
-
Subscription
- ${subBox} -
- - ${rawBtn} - ${rawBox} - `; -} - -// ------ Sections ------ -function setHealth(ok, vq, unitsActive) { - healthDot.classList.toggle('ok', !!ok); - healthDot.classList.toggle('bad', !ok); - healthTitle.textContent = ok ? 'HA: OK' : 'HA: PROBLEM'; - healthSub.textContent = `Quorate=${String(vq.quorate)} | units=${unitsActive ? 'active' : 'inactive'} | members=${safe(vq.members)} | quorum=${safe(vq.quorum)}/${safe(vq.expected)}`; -} - -function renderClusterCards(arr) { - const a = ensureArr(arr); qCardsWrap.innerHTML = ''; - if (!a.length) { qCardsWrap.innerHTML = badge('No data', 'dark'); return; } - const cluster = a.find(x => x.type === 'cluster') || {}; - const qB = cluster.quorate ? badge('Quorate: yes', 'ok') : badge('Quorate: no', 'err'); - qCardsWrap.insertAdjacentHTML('beforeend', ` -
-
-
${safe(cluster.name)}
-
id: ${safe(cluster.id)}
-
${qB}
-
nodes: ${safe(cluster.nodes)}
-
version: ${safe(cluster.version)}
-
-
`); - const nodes = a.filter(x => x.type === 'node'); - const rows = nodes.map(n => { - const online = n.online ? badge('online', 'ok') : badge('offline', 'err'); - const local = n.local ? ' ' + badge('local', 'info') : ''; - return rowHTML([safe(n.name), online + local, safe(n.ip), safe(n.nodeid), safe(n.level)]); - }); - qCardsWrap.insertAdjacentHTML('beforeend', ` -
- - - ${rows.join('')} -
NodeStatusIPNodeIDLevel
-
`); -} - -function renderUnits(units) { - unitsBox.innerHTML = ''; - if (!units || !Object.keys(units).length) { unitsBox.innerHTML = badge('No data', 'dark'); return; } - const map = { active: 'ok', inactive: 'err', failed: 'err', activating: 'warn' }; - Object.entries(units).forEach(([k, v]) => unitsBox.insertAdjacentHTML('beforeend', `${badge(k, map[v] || 'dark')}`)); -} - -function parsePveSr(text) { - const lines = (text || '').split('\n').map(s => s.trim()).filter(Boolean), out = []; - for (const ln of lines) { - if (ln.startsWith('JobID')) continue; - const m = ln.match(/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+)$/); - if (m) out.push({ job: m[1], enabled: m[2], target: m[3], last: m[4], next: m[5], dur: m[6], fail: +m[7], state: m[8] }); - } return out; -} -function renderReplication(text) { - const arr = parsePveSr(text); - if (!arr.length) { replBox && (replBox.innerHTML = 'No replication jobs'); return; } - const rows = arr.map(x => { - const en = /^yes$/i.test(x.enabled) ? badge('Yes', 'ok') : badge('No', 'err'); - const st = /^ok$/i.test(x.state) ? badge(x.state, 'ok') : badge(x.state, 'err'); - const fc = x.fail > 0 ? badge(String(x.fail), 'err') : badge(String(x.fail), 'ok'); - return rowHTML([x.job, en, x.target, x.last, x.next, x.dur, fc, st]); - }); - replBox.innerHTML = `
- - ${rows.join('')}
JobIDEnabledTargetLastSyncNextSyncDurationFailCountState
`; -} - -// HA Resources table (expandable rows) -function renderHAResources(list) { - const arr = ensureArr(list); const rows = []; - arr.forEach(x => { - const st = x.state || '—'; - const stB = /start/i.test(st) ? badge(st, 'ok') : (/stop/i.test(st) ? badge(st, 'err') : badge(st, 'dark')); - const sid = safe(x.sid); - rows.push(rowHTML([sid, stB, safe(x.node), safe(x.group), safe(x.flags || x.comment)], `class="vm-row" data-sid="${sid}"`)); - rows.push(`
`); - }); - setRows(tblHaRes, rows.length ? rows : [rowHTML(['—', '—', '—', '—', '—'])]); - Array.from(document.querySelectorAll('#ha-res tr.vm-row')).forEach((tr, i) => { - tr.onclick = async () => { - const detailRow = tblHaRes.querySelectorAll('tr.vm-detail')[i]; - const content = detailRow.querySelector('.vm-json'); - const spin = detailRow.querySelector('.spinner-border'); - const open = detailRow.classList.contains('d-none'); - document.querySelectorAll('#ha-res tr.vm-detail').forEach(r => r.classList.add('d-none')); - if (open) { - detailRow.classList.remove('d-none'); spin.classList.remove('d-none'); - const sid = tr.getAttribute('data-sid'); - try { const d = await fetchVmDetail(sid); content.innerHTML = renderVmDetailCard(d); } - catch (e) { content.textContent = 'ERROR: ' + e; } - spin.classList.add('d-none'); - } - }; - }); -} - -function renderHAStatus(list) { - const st = ensureArr(list); - if (!st.length) { setRows(tblHaStatus, [rowHTML(['—', '—', '—', '—'])]); return; } - const rows = st.map(n => { - const sB = /active|online/i.test(n.state || '') ? badge(n.state, 'ok') : badge(safe(n.state || '—'), 'warn'); - const crmB = /active/i.test(n.crm_state || '') ? badge(n.crm_state, 'ok') : badge(safe(n.crm_state || '—'), 'err'); - const lrmB = /active/i.test(n.lrm_state || '') ? badge(n.lrm_state, 'ok') : badge(safe(n.lrm_state || '—'), 'err'); - return rowHTML([safe(n.node), sB, crmB, lrmB]); - }); - setRows(tblHaStatus, rows); -} - -// Non-HA VM/CT table -async function renderNonHA() { - const r = await fetch('/api/list-vmct'); - const d = await r.json(); - const arr = ensureArr(d.nonha); - if (!arr.length) { setRows(tblNonHA, [rowHTML(['No non-HA VMs/CTs'])]); return; } - const rows = []; - arr.forEach(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'); - rows.push(rowHTML([sid, type, name, node, st], `class="vm-row" data-sid="${sid}` + "\"")); - rows.push(`
`); - }); - setRows(tblNonHA, rows); - Array.from(document.querySelectorAll('#nonha tr.vm-row')).forEach((tr, i) => { - tr.onclick = async () => { - const detailRow = tblNonHA.querySelectorAll('tr.vm-detail')[i]; - const content = detailRow.querySelector('.vm-json'); - const spin = detailRow.querySelector('.spinner-border'); - const open = detailRow.classList.contains('d-none'); - document.querySelectorAll('#nonha tr.vm-detail').forEach(r => r.classList.add('d-none')); - if (open) { - detailRow.classList.remove('d-none'); spin.classList.remove('d-none'); - const sid = tr.getAttribute('data-sid'); - try { const d = await fetchVmDetail(sid); content.innerHTML = renderVmDetailCard(d); } - catch (e) { content.textContent = 'ERROR: ' + e; } - spin.classList.add('d-none'); - } - }; - }); -} - -// Nodes table (expandable) + sticky first column + robust online detect -function renderNodesTable(nodes) { - const nrows = ensureArr(nodes).map(n => { - const isOn = /online|running/i.test(n.status || '') || - /online/i.test(n.hastate || '') || - (n.uptime > 0) || - (n.cpu != null && n.maxcpu != null) || - (n.mem != null && n.maxmem != null); - const statusTxt = isOn ? 'online' : (n.status || 'offline'); - const sB = isOn ? badge(statusTxt, 'ok') : badge(statusTxt, 'err'); - - const mem = (n.mem != null && n.maxmem) ? `${humanBytes(n.mem)} / ${humanBytes(n.maxmem)} (${pct(n.mem / n.maxmem)})` : '—'; - const rfs = (n.rootfs != null && n.maxrootfs) ? `${humanBytes(n.rootfs)} / ${humanBytes(n.maxrootfs)} (${pct(n.rootfs / n.maxrootfs)})` : '—'; - const load = (n.loadavg != null) ? String(n.loadavg) : '—'; - const cpu = (n.cpu != null) ? pct(n.cpu) : '—'; - - const main = ` - ${safe(n.node)} - ${sB}${cpu}${load}${mem}${rfs}${fmtSeconds(n.uptime)} - `; - - const detail = ` - -
-
- - `; - - return main + detail; - }); - setRows(tblNodes, nrows); - - Array.from(document.querySelectorAll('#nodes tr.node-row')).forEach((tr, i) => { - tr.onclick = async () => { - const detailRow = tblNodes.querySelectorAll('tr.node-detail')[i]; - const content = detailRow.querySelector('.node-json'); - const spin = detailRow.querySelector('.spinner-border'); - const open = detailRow.classList.contains('d-none'); - document.querySelectorAll('#nodes tr.node-detail').forEach(r => r.classList.add('d-none')); - if (open) { - detailRow.classList.remove('d-none'); spin.classList.remove('d-none'); - const name = tr.getAttribute('data-node'); - try { const d = await fetchNodeDetail(name); content.innerHTML = renderNodeDetailCard(d); } - catch (e) { content.textContent = 'ERROR: ' + e; } - spin.classList.add('d-none'); - } - }; - }); -} - -// ------ main render ------ -function renderSnap(d) { - const vq = d.votequorum || {}; const units = d.units || {}; const allUnits = Object.values(units).every(v => v === 'active'); - const ok = (vq.quorate === 'yes') && allUnits; - setHealth(ok, vq, allUnits); - - const gl = document.getElementById('global-loading'); if (gl) gl.remove(); - - qSummary.textContent = `Quorate: ${safe(vq.quorate)} | members: ${safe(vq.members)} | expected: ${safe(vq.expected)} | total: ${safe(vq.total)} | quorum: ${safe(vq.quorum)}`; - renderClusterCards(d.api && d.api.cluster_status); - renderUnits(units); - renderReplication(d.replication); - renderHAResources(d.api && d.api.ha_resources); - renderHAStatus(d.api && d.api.ha_status); - renderNodesTable(d.api && d.api.nodes); - - pvecmPre.textContent = safe(d.pvecm); - cfgtoolPre.textContent = safe(d.cfgtool); - - footer.textContent = `node_arg=${safe(d.node_arg)} | host=${safe(d.hostname)} | ts=${new Date((d.ts || 0) * 1000).toLocaleString()}`; -} - -// 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(['Brak VM/CT'])]); 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 = ``; - const migrateBtn = `
`; - return rowHTML([sid, type.toUpperCase(), name, node, st, actions, sel, migrateBtn], `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 colSpan = tr.children.length; // for status row width - const bind = (sel, action, needsTarget = false) => { - const btn = tr.querySelector(sel); - if (!btn) return; - btn.onclick = async () => { - setRowBusy(tr, true); - btn.disabled = true; - try { - if (action === 'migrate') { - const target = getTarget(); - const resp = await vmAction(sid, action, target); - // show expandable status row (spinner already set via setRowBusy) - const row = ensureMigRow(tr, colSpan); - setMigRowVisible(row, true); - const log = row.querySelector('.mig-log'); - const srcNode = resp.source_node; - const upid = resp.upid; - const stopSig = { done: false }; - tailTaskLog(upid, srcNode, log, stopSig); - - log.textContent = `Starting offline migrate to ${target} (UPID: ${upid || '—'})...`; - // update badge immediately - const badgeCell = tr.children[4]; - if (badgeCell) badgeCell.innerHTML = badge('migrating', 'info'); - // start background polling; update log on each tick - await pollTask(upid, srcNode, (st) => { - const lines = []; - for (const k of ['type', 'status', 'pid', 'starttime', 'user', 'node', 'endtime', 'exitstatus']) { - if (st && st[k] !== undefined) lines.push(`${k}: ${st[k]}`); - } - log.textContent = lines.join('\n') || '—'; - }, async (finalSt) => { - stopSig.done = true; - const exit = (finalSt && finalSt.exitstatus) ? String(finalSt.exitstatus) : ''; - const ok = exit.toUpperCase() === 'OK'; - const badgeCell = tr.children[4]; - if (badgeCell) badgeCell.innerHTML = ok ? badge('running', 'ok') : badge('migrate error', 'err'); - log.textContent += (log.textContent ? '\n' : '') + (ok ? 'Migration finished successfully.' : ('Migration failed: ' + (exit || 'unknown error'))); - setRowBusy(tr, false); - await doRefresh(); - // auto-collapse on success after 2s - if (ok) { setTimeout(() => { const row = tr.nextElementSibling; if (row && row.classList.contains('mig-row')) setMigRowVisible(row, false); }, 2000); } - }); - } else { - await vmAction(sid, action, needsTarget ? getTarget() : undefined); - showToast('Success', `${action} executed for ${sid}`, 'success'); - await doRefresh(); - setRowBusy(tr, false); - } - } catch (e) { showToast('Error', 'ERROR: ' + e.message, 'danger'); } - btn.disabled = false; - }; - }; - bind('.act-unlock', 'unlock'); - bind('.act-start', 'start'); - bind('.act-stop', 'stop'); - bind('.act-shutdown', 'shutdown'); - bind('.act-migrate', 'migrate', true); - const statusBtn = tr.querySelector('.act-status'); - if (statusBtn) { - statusBtn.onclick = () => { - const row = ensureMigRow(tr, colSpan); - setMigRowVisible(row, row.classList.contains('d-none')); - }; - } - }); -} - - - -async function pollTask(upid, node, onUpdate, onDone) { - let lastSt = null; - if (!upid || !node) return; - const started = Date.now(); - const maxMs = 5 * 60 * 1000; - const delay = (ms) => new Promise(r => setTimeout(r, ms)); - while (Date.now() - started < maxMs) { - try { - const r = await fetch('/api/task-status?upid=' + encodeURIComponent(upid) + '&node=' + encodeURIComponent(node)); - const d = await r.json(); - if (d && d.ok) { - const st = d.status || {}; - lastSt = st; - if (onUpdate) { try { onUpdate(st); } catch { } } - const s = (st.status || '').toLowerCase(); - if (s === 'stopped' || st.exitstatus) break; - } - } catch (e) { } - await delay(2000); - } - try { if (onDone) onDone(lastSt); } catch { } -} - - -function ensureMigRow(mainTr, colSpan) { - let nxt = mainTr.nextElementSibling; - if (!nxt || !nxt.classList.contains('mig-row')) { - nxt = document.createElement('tr'); - nxt.className = 'mig-row d-none'; - const td = document.createElement('td'); - td.colSpan = colSpan; - td.innerHTML = '
Migration status
'; - nxt.appendChild(td); - mainTr.parentNode.insertBefore(nxt, mainTr.nextSibling); - } - return nxt; -} -function setMigRowVisible(row, vis) { - row.classList.toggle('d-none', !vis); -} - -function setRowBusy(tr, busy) { - const nameCell = tr.children[2]; - if (!nameCell) return; - let spin = nameCell.querySelector('.op-spin'); - if (busy) { - if (!spin) { - const span = document.createElement('span'); - span.className = 'op-spin spinner-border spinner-border-sm align-middle ms-2'; - span.setAttribute('role', 'status'); - span.setAttribute('aria-hidden', 'true'); - nameCell.appendChild(span); - } - } else { - if (spin) spin.remove(); - } -} - - -function showToast(title, body, variant) { - const cont = document.getElementById('toast-container'); - if (!cont) { console.warn('toast container missing'); return; } - const id = 't' + Math.random().toString(36).slice(2); - const vcls = { - success: 'text-bg-success', - info: 'text-bg-info', - warning: 'text-bg-warning', - danger: 'text-bg-danger', - secondary: 'text-bg-secondary' - }[variant || 'secondary']; - const el = document.createElement('div'); - el.className = 'toast align-items-center ' + vcls; - el.setAttribute('role', 'alert'); - el.setAttribute('aria-live', 'assertive'); - el.setAttribute('aria-atomic', 'true'); - el.innerHTML = ` -
-
- ${title || ''}${title ? ': ' : ''}${body || ''} -
- -
`; - cont.appendChild(el); - const t = new bootstrap.Toast(el, { delay: 5000 }); - t.show(); - el.addEventListener('hidden.bs.toast', () => el.remove()); -} - - -async function tailTaskLog(upid, node, preEl, stopSignal) { - let start = 0; - const delay = (ms) => new Promise(r => setTimeout(r, ms)); - const append = (txt) => { - if (!preEl) return; - preEl.textContent = (preEl.textContent ? preEl.textContent + '\n' : '') + txt; - trimLogEl(preEl); - preEl.scrollTop = preEl.scrollHeight; - }; - while (!stopSignal.done) { - try { - const r = await fetch('/api/task-log?upid=' + encodeURIComponent(upid) + '&node=' + encodeURIComponent(node) + '&start=' + start); - const d = await r.json(); - if (d && d.ok) { - const lines = Array.isArray(d.lines) ? d.lines : []; - for (const ln of lines) { - if (ln && typeof ln.t === 'string') append(ln.t); - } - start = d.next_start ?? start; - } - } catch (e) { - // transient errors ignored - } - await delay(1500); - } -} - - -const LOG_MAX_CHARS = 64 * 1024; // 64KB cap for log panel -function trimLogEl(preEl) { - if (!preEl) return; - const txt = preEl.textContent || ''; - if (txt.length > LOG_MAX_CHARS) { - preEl.textContent = txt.slice(-LOG_MAX_CHARS); - } -} \ No newline at end of file diff --git a/static/styles.css b/static/styles.css index 7db1caf..4f00865 100644 --- a/static/styles.css +++ b/static/styles.css @@ -97,4 +97,49 @@ footer.site-footer a:hover { .position-fixed.bottom-0.end-0.p-3 { right: max(env(safe-area-inset-right), 1rem); bottom: max(env(safe-area-inset-bottom), 1rem); +} + +/* Row chevron (expandable rows) */ +.table .chev { + width: 1.25rem; + text-align: center; + user-select: none; +} + +.table tr.expandable { + cursor: pointer; +} + +.table tr.expandable .chev::before { + content: "▸"; + display: inline-block; + transition: transform .15s ease; +} + +.table tr.expanded .chev::before { + transform: rotate(90deg); + content: "▾"; +} + +/* Small utility widths */ +.w-1 { + width: 1.25rem; +} + +/* Subtle skeleton */ +.skel { + position: relative; + background: linear-gradient(90deg, rgba(255, 255, 255, .05) 25%, rgba(255, 255, 255, .10) 37%, rgba(255, 255, 255, .05) 63%); + background-size: 400% 100%; + animation: skel 1.4s ease-in-out infinite; +} + +@keyframes skel { + 0% { + background-position: 100% 0; + } + + 100% { + background-position: 0 0; + } } \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index c1a212c..b27eb29 100644 --- a/templates/index.html +++ b/templates/index.html @@ -15,8 +15,9 @@

PVE HA Panel

- - + +
@@ -43,14 +44,14 @@
-
+ Loading data…
-
+
Loading…
@@ -137,6 +138,7 @@ + @@ -146,36 +148,7 @@
SID State Node
-
Click a row to expand VM/CT details.
-
-
-
- - -
-

- -

-
-
- - - - - - - - - - - - - - -
NodeStatusCRMLRM
Loading…
+
Kliknij wiersz, aby rozwinąć szczegóły VM/CT.
@@ -212,6 +185,7 @@ + @@ -221,11 +195,11 @@ - +
SID Type Name
Loading…Loading…
-
Click a row to expand VM/CT details.
+
Kliknij wiersz, aby rozwinąć szczegóły VM/CT.
@@ -253,7 +227,8 @@ -
Akcje: Unlock (qm), Start/Stop/Shutdown, Offline migrate.
+
Akcje: Unlock (qm), Start/Stop/Shutdown, Offline migrate. Postęp widoczny na + żywo per wiersz.
@@ -265,6 +240,7 @@ + @@ -280,11 +256,10 @@
Node Status CPU
-
Click a row to expand node details.
+
Kliknij wiersz, aby rozwinąć szczegóły noda.
- @@ -301,7 +276,6 @@ - @@ -310,7 +284,8 @@ - + + \ No newline at end of file From ff4944ffb478237156ee2ed8f7c7c5f213f43ba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 21:18:36 +0200 Subject: [PATCH 02/28] refator_comm1 --- app.py | 203 ++++++++++++++++++++++++++++++++++----- static/js/admin.js | 234 +++++++++++++++++++++++++-------------------- static/js/api.js | 14 ++- 3 files changed, 322 insertions(+), 129 deletions(-) diff --git a/app.py b/app.py index 14fc8ed..ce95e6b 100644 --- a/app.py +++ b/app.py @@ -10,12 +10,16 @@ import subprocess from typing import List, Dict, Any, Optional, Tuple from flask import Flask, request, jsonify, render_template +# NEW: WebSockets +from flask_sock import Sock + APP_TITLE = "PVE HA Panel" DEFAULT_NODE = socket.gethostname() HA_UNITS_START = ["watchdog-mux", "pve-ha-crm", "pve-ha-lrm"] HA_UNITS_STOP = list(reversed(HA_UNITS_START)) app = Flask(__name__, template_folder="templates", static_folder="static") +sock = Sock(app) # NEW # ---------------- exec helpers ---------------- def run(cmd: List[str], timeout: int = 25) -> subprocess.CompletedProcess: @@ -41,12 +45,16 @@ def post_json(cmd: List[str], timeout: Optional[int] = None) -> Any: if cmd and cmd[0] == "pvesh" and len(cmd) > 2 and cmd[1] != "create": cmd = ["pvesh", "create"] + cmd[1:] r = run(cmd, timeout=timeout or 25) - if r.returncode != 0 or not r.stdout.strip(): - return None + if r.returncode != 0: + # pvesh create zwykle zwraca JSON tylko przy 200 + try: + return json.loads(r.stdout) if r.stdout.strip() else {"error": r.stderr.strip(), "rc": r.returncode} + except Exception: + return {"error": r.stderr.strip(), "rc": r.returncode} try: - return json.loads(r.stdout) + return json.loads(r.stdout) if r.stdout.strip() else {} except Exception: - return None + return {} def is_active(unit: str) -> bool: return run(["systemctl", "is-active", "--quiet", unit]).returncode == 0 @@ -106,8 +114,7 @@ def enrich_nodes(nodes_list: List[Dict[str, Any]]) -> List[Dict[str, Any]]: for n in nodes_list or []: name = n.get("node") if not name: - out.append(n) - continue + out.append(n); continue detail = get_json(["pvesh", "get", f"/nodes/{name}/status"]) or {} if "loadavg" in detail: n["loadavg"] = detail["loadavg"] if "cpu" in detail: n["cpu"] = detail["cpu"] @@ -374,9 +381,7 @@ def api_replication_all(): jobs: List[Dict[str, Any]] = [] nodes = [n.get("node") for n in (api_cluster_data().get("nodes") or []) if n.get("node")] for name in nodes: - # PVE 7/8: GET /nodes/{node}/replication lub /nodes/{node}/replication/jobs data = get_json(["pvesh", "get", f"/nodes/{name}/replication"]) or get_json(["pvesh","get",f"/nodes/{name}/replication/jobs"]) or [] - # fallback: pvesr status na zdalnym nodzie bywa utrudnione, więc parsujemy standardową strukturę API for it in (data or []): jobs.append({ "node": name, @@ -391,8 +396,7 @@ def api_replication_all(): }) return jsonify({ "jobs": jobs }) -# --- istniejące endpointy detali i list --- - +# --- Detale --- @app.get("/api/vm") def api_vm_detail(): sid = request.args.get("sid", "") @@ -410,6 +414,7 @@ def api_list_vmct(): nonha = [v for k, v in meta.items() if k not in ha_sids] return jsonify({"nonha": nonha, "ha_index": list(ha_sids), "count_nonha": len(nonha), "count_all_vmct": len(meta)}) +# --- Enable/Disable maintenance --- @app.post("/api/enable") def api_enable(): if os.geteuid() != 0: @@ -432,7 +437,7 @@ def api_disable(): ha_node_maint(False, node, log) return jsonify(ok=True, log=log) -# --- VM/CT admin actions API (pozostaje jak było; bez usuwania) --- +# --- VM/CT admin actions API (zwrot UPID dla live) --- def vm_locked(typ: str, node: str, vmid: int) -> bool: base = f"/nodes/{node}/{typ}/{vmid}" @@ -466,32 +471,40 @@ def api_vm_action(): if not node: return jsonify(ok=False, error="unknown node"), 400 - if action == "migrate_offline": - action = "migrate" - try: if action == "unlock": + # brak UPID (działa natychmiast) 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) + return jsonify(ok=(r.returncode == 0), stdout=r.stdout, stderr=r.stderr, upid=None, source_node=node) 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, timeout=120) + # PVE API -> zwraca UPID + base = f"/nodes/{node}/{typ}/{vmid}/status/{action}" + res = post_json(["pvesh", "create", base], timeout=120) or {} upid = None if isinstance(res, dict): upid = res.get("data") or res.get("upid") - return jsonify(ok=True, result=res or {}, upid=upid, source_node=node) + if not upid: + # fallback do qm/pct (bez UPID), ale zwrócimy ok + r = run_qm_pct(typ, vmid, action) + return jsonify(ok=(r.returncode == 0), stdout=r.stdout, stderr=r.stderr, upid=None, source_node=node) + return jsonify(ok=True, result=res, upid=upid, source_node=node) + + elif action == "migrate" or action == "migrate_offline": + if action == "migrate_offline": # zgodność wstecz + 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" + res = post_json(["pvesh", "create", base, "-target", target, "-online", "0"], timeout=120) or {} + upid = None + if isinstance(res, dict): + upid = res.get("data") or res.get("upid") + return jsonify(ok=True, result=res, upid=upid, source_node=node) else: return jsonify(ok=False, error="unknown action"), 400 @@ -528,6 +541,146 @@ def api_task_log(): next_start = start_i return jsonify(ok=True, lines=lines or [], next_start=next_start) +# ---------------- WebSocket: live tail zadań ---------------- +@sock.route("/ws/task") +def ws_task(ws): + # query: upid, node + q = ws.environ.get("QUERY_STRING", "") + params = {} + for part in q.split("&"): + if not part: continue + k, _, v = part.partition("=") + params[k] = v + upid = params.get("upid", "").strip() + node = params.get("node", "").strip() + if not upid or not node: + ws.send(json.dumps({"type":"error","error":"upid and node are required"})) + return + start = 0 + try: + while True: + st = get_json(["pvesh", "get", f"/nodes/{node}/tasks/{upid}/status"]) or {} + ws.send(json.dumps({"type":"status","status":st})) + lines = get_json(["pvesh", "get", f"/nodes/{node}/tasks/{upid}/log", "-start", str(start)]) or [] + if isinstance(lines, list) and lines: + for ln in lines: + txt = (ln.get("t") if isinstance(ln, dict) else None) + if txt: + ws.send(json.dumps({"type":"log","line":txt})) + try: + start = max((int(x.get("n", start)) for x in lines if isinstance(x, dict)), default=start) + 1 + except Exception: + pass + # koniec + if isinstance(st, dict) and (str(st.get("status","")).lower() == "stopped" or st.get("exitstatus")): + ok = (str(st.get("exitstatus","")).upper() == "OK") + ws.send(json.dumps({"type":"done","ok":ok,"exitstatus":st.get("exitstatus")})) + break + time.sleep(1.2) + except Exception: + try: + ws.close() + except Exception: + pass + +# ---------------- WebSocket: live tail zadań ---------------- +@sock.route("/ws/task") +def ws_task(ws): + # (bez zmian – jak w mojej poprzedniej wersji) + q = ws.environ.get("QUERY_STRING", "") + params = {} + for part in q.split("&"): + if not part: continue + k, _, v = part.partition("=") + params[k] = v + upid = params.get("upid", "").strip() + node = params.get("node", "").strip() + if not upid or not node: + ws.send(json.dumps({"type":"error","error":"upid and node are required"})) + return + start = 0 + try: + while True: + st = get_json(["pvesh", "get", f"/nodes/{node}/tasks/{upid}/status"]) or {} + ws.send(json.dumps({"type":"status","status":st})) + lines = get_json(["pvesh", "get", f"/nodes/{node}/tasks/{upid}/log", "-start", str(start)]) or [] + if isinstance(lines, list) and lines: + for ln in lines: + txt = (ln.get("t") if isinstance(ln, dict) else None) + if txt: + ws.send(json.dumps({"type":"log","line":txt})) + try: + start = max((int(x.get("n", start)) for x in lines if isinstance(x, dict)), default=start) + 1 + except Exception: + pass + if isinstance(st, dict) and (str(st.get("status","")).lower() == "stopped" or st.get("exitstatus")): + ok = (str(st.get("exitstatus","")).upper() == "OK") + ws.send(json.dumps({"type":"done","ok":ok,"exitstatus":st.get("exitstatus")})) + break + time.sleep(1.2) + except Exception: + try: ws.close() + except Exception: pass + +# ---------------- WebSocket: broadcast observe per sid ---------------- +@sock.route("/ws/observe") +def ws_observe(ws): + """ + Streamuje zmiany stanu VM/CT i aktywne taski dla danego SID. + Query: sid=vm:101 (albo ct:123) + """ + q = ws.environ.get("QUERY_STRING", "") + params = {} + for part in q.split("&"): + if not part: continue + k, _, v = part.partition("="); params[k] = v + sid = (params.get("sid") or "").strip() + if not sid: + ws.send(json.dumps({"type":"error","error":"sid required"})); return + + meta = cluster_vmct_meta() + tup = sid_to_tuple(sid, meta) + if not tup: + ws.send(json.dumps({"type":"error","error":"unknown sid"})); return + typ, vmid, node = tup + if not node: + ws.send(json.dumps({"type":"error","error":"could not resolve node"})); return + + last_hash = None + seen_upids = set() + try: + while True: + base = f"/nodes/{node}/{typ}/{vmid}" + cur = get_json(["pvesh", "get", f"{base}/status/current"]) or {} + # wyślij tylko gdy zmiana + cur_hash = json.dumps(cur, sort_keys=True) + if cur_hash != last_hash: + last_hash = cur_hash + ws.send(json.dumps({"type":"vm","current":cur,"meta":{"sid":norm_sid(sid),"node":node,"typ":typ,"vmid":vmid}})) + + tasks = get_json(["pvesh","get",f"/nodes/{node}/tasks","-limit","50"]) or [] + for t in tasks: + upid = t.get("upid") or t.get("pstart") # upid musi być stringiem + tid = (t.get("id") or "") # np. "qemu/101" lub "lxc/123" + if not isinstance(upid, str): continue + if (str(vmid) in tid) or (f"{'qemu' if typ=='qemu' else 'lxc'}/{vmid}" in tid): + # status szczegółowy + st = get_json(["pvesh","get",f"/nodes/{node}/tasks/{upid}/status"]) or {} + ev = {"type":"task","upid":upid,"status":st.get("status"),"exitstatus":st.get("exitstatus"),"node":node} + ws.send(json.dumps(ev)) + # nowy "running" task → ogłoś raz + if upid not in seen_upids and (str(st.get("status","")).lower() != "stopped"): + seen_upids.add(upid) + ws.send(json.dumps({"type":"task-start","upid":upid,"node":node})) + # zakończenie + if str(st.get("status","")).lower() == "stopped" or st.get("exitstatus"): + ws.send(json.dumps({"type":"done","upid":upid,"ok":str(st.get('exitstatus','')).upper()=='OK'})) + + time.sleep(2.0) + except Exception: + try: ws.close() + except Exception: pass + if __name__ == "__main__": import argparse p = argparse.ArgumentParser() diff --git a/static/js/admin.js b/static/js/admin.js index e25fa6c..7ca0f57 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -21,130 +21,160 @@ export async function renderVMAdmin() { const sel = ``; - const migrateBtn = `
+ const tools = `
-
`; - return rowHTML([sid, type.toUpperCase(), name, node, st, actions, sel, migrateBtn], `data-sid="${sid}"`); + + +
`; + return rowHTML([sid, type.toUpperCase(), name, node, st, actions, sel, tools], `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 colSpan = tr.children.length; + const badgeCell = tr.children[4]; + + // subpanel (log) + let sub = tr.nextElementSibling; + if (!sub || !sub.classList.contains('mig-row')) { + sub = document.createElement('tr'); sub.className = 'mig-row d-none'; + const td = document.createElement('td'); td.colSpan = colSpan; + td.innerHTML = '
Task status
'; + sub.appendChild(td); tr.parentNode.insertBefore(sub, tr.nextSibling); + } + const logPre = sub.querySelector('.mig-log'); + const toggleSub = (show) => sub.classList.toggle('d-none', !show); + + let wsObs = null; // observe websocket + let wsTask = null; // tail websocket (auto z observe) + + const closeWS = () => { try { wsObs && wsObs.close(); } catch {} try { wsTask && wsTask.close(); } catch {} wsObs = wsTask = null; }; const setRowBusy = (busy) => { - const nameCell = tr.children[2]; if (!nameCell) return; + const nameCell = tr.children[2]; let spin = nameCell.querySelector('.op-spin'); - if (busy) { - if (!spin) { - const span = document.createElement('span'); - span.className = 'op-spin spinner-border spinner-border-sm align-middle ms-2'; - span.setAttribute('role','status'); span.setAttribute('aria-hidden','true'); - nameCell.appendChild(span); - } - } else { if (spin) spin.remove(); } - }; - - const ensureMigRow = () => { - let nxt = tr.nextElementSibling; - if (!nxt || !nxt.classList.contains('mig-row')) { - nxt = document.createElement('tr'); nxt.className = 'mig-row d-none'; - const td = document.createElement('td'); td.colSpan = colSpan; - td.innerHTML = '
Migration status
'; - nxt.appendChild(td); tr.parentNode.insertBefore(nxt, tr.nextSibling); - } return nxt; - }; - const setMigRowVisible = (row, vis) => row.classList.toggle('d-none', !vis); - - const tailTaskLog = async (upid, node, preEl, stopSignal) => { - let start = 0; const delay = (ms)=>new Promise(r=>setTimeout(r,ms)); - const append = (txt) => { if (!preEl) return; preEl.textContent = (preEl.textContent?preEl.textContent+'\n':'') + txt; preEl.scrollTop = preEl.scrollHeight; }; - while (!stopSignal.done) { - try { - const d = await api.taskLog(upid, node, start); - if (d && d.ok) { - const lines = Array.isArray(d.lines) ? d.lines : []; - for (const ln of lines) if (ln && typeof ln.t === 'string') append(ln.t); - start = d.next_start ?? start; - } - } catch {} - await delay(1500); + if (busy && !spin) { + const span = document.createElement('span'); + span.className = 'op-spin spinner-border spinner-border-sm align-middle ms-2'; + span.setAttribute('role','status'); span.setAttribute('aria-hidden','true'); + nameCell.appendChild(span); } + if (!busy && spin) spin.remove(); }; - const pollTask = async (upid, node, onUpdate, onDone) => { - let lastSt = null; if (!upid || !node) return; - const started = Date.now(); const maxMs = 5*60*1000; - const delay = (ms)=>new Promise(r=>setTimeout(r,ms)); - while (Date.now() - started < maxMs) { - try { - const d = await api.taskStatus(upid, node); - if (d && d.ok) { - const st = d.status || {}; lastSt = st; - try { onUpdate && onUpdate(st); } catch {} - const s = (st.status||'').toLowerCase(); - if (s === 'stopped' || st.exitstatus) break; - } - } catch {} - await delay(2000); - } - try { onDone && onDone(lastSt); } catch {} - }; + const getTarget = () => tr.querySelector('.target-node')?.value || ''; - const bind = (selector, action, needsTarget=false) => { - const btn = tr.querySelector(selector); if (!btn) return; - btn.onclick = async () => { - setRowBusy(true); btn.disabled = true; + const openTaskWS = (upid, node) => { + if (!upid) return; + toggleSub(true); + logPre.textContent = `UPID: ${upid} @ ${node}\n`; + wsTask = new WebSocket(api.wsTaskURL(upid, node)); + wsTask.onmessage = (ev) => { try { - if (action === 'migrate') { - const target = getTarget(); - const resp = await api.vmAction(sid, action, target); - const row = ensureMigRow(); setMigRowVisible(row, true); - const log = row.querySelector('.mig-log'); - const srcNode = resp.source_node; const upid = resp.upid; const stopSig = { done:false }; - tailTaskLog(upid, srcNode, log, stopSig); - log.textContent = `Starting offline migrate to ${target} (UPID: ${upid || '—'})...`; - const badgeCell = tr.children[4]; if (badgeCell) badgeCell.innerHTML = badge('migrating','info'); - await pollTask(upid, srcNode, (st) => { - const keys = ['type','status','pid','starttime','user','node','endtime','exitstatus']; - const lines = keys.filter(k=>st && st[k]!==undefined).map(k=>`${k}: ${st[k]}`); - log.textContent = lines.join('\n') || '—'; - }, async (finalSt) => { - stopSig.done = true; - const exit = (finalSt && finalSt.exitstatus) ? String(finalSt.exitstatus) : ''; - const ok = exit.toUpperCase() === 'OK'; - const badgeCell = tr.children[4]; - if (badgeCell) badgeCell.innerHTML = ok ? badge('running','ok') : badge('migrate error','err'); - log.textContent += (log.textContent ? '\n' : '') + (ok ? 'Migration finished successfully.' : ('Migration failed: ' + (exit || 'unknown error'))); - setRowBusy(false); - // odśwież minimalnie: tylko ten wiersz przez szybkie /api/list-all-vmct? (tu prosty full-refresh stanu) - try { document.getElementById('btnRefresh').click(); } catch {} - if (ok) setTimeout(()=>{ const row = tr.nextElementSibling; if (row && row.classList.contains('mig-row')) setMigRowVisible(row,false); }, 2000); - }); - } else { - await api.vmAction(sid, action, needsTarget ? getTarget() : undefined); - showToast('Success', `${action} executed for ${sid}`, 'success'); + const msg = JSON.parse(ev.data); + if (msg.type === 'log' && msg.line) { + logPre.textContent += msg.line + '\n'; + logPre.scrollTop = logPre.scrollHeight; + } else if (msg.type === 'status' && msg.status) { + const ok = String(msg.status.exitstatus||'').toUpperCase() === 'OK'; + const stopped = String(msg.status.status||'').toLowerCase() === 'stopped'; + if (badgeCell) badgeCell.innerHTML = ok ? badge('running','ok') : (stopped ? badge('stopped','dark') : badge('working','info')); + } else if (msg.type === 'done') { + const ok = !!msg.ok; + if (badgeCell) badgeCell.innerHTML = ok ? badge('running','ok') : badge('error','err'); setRowBusy(false); + setTimeout(() => toggleSub(false), 1200); try { document.getElementById('btnRefresh').click(); } catch {} } - } catch (e) { showToast('Error', 'ERROR: ' + (e.message || e), 'danger'); setRowBusy(false); } - btn.disabled = false; + } catch {} }; + wsTask.onerror = () => {}; }; - bind('.act-unlock','unlock'); - bind('.act-start','start'); - bind('.act-stop','stop'); - bind('.act-shutdown','shutdown'); - bind('.act-migrate','migrate', true); - - const statusBtn = tr.querySelector('.act-status'); - if (statusBtn) statusBtn.onclick = () => { - const row = tr.nextElementSibling && tr.nextElementSibling.classList.contains('mig-row') ? tr.nextElementSibling : null; - if (!row) return; - const vis = row.classList.contains('d-none'); row.classList.toggle('d-none', !vis); + const openObserveWS = () => { + if (wsObs) return; + const url = api.wsObserveURL(sid); + wsObs = new WebSocket(url); + wsObs.onmessage = (ev) => { + try { + const msg = JSON.parse(ev.data); + if (msg.type === 'vm' && msg.current) { + // aktualizuj badge na podstawie bieżącego statusu + const st = String(msg.current.status || msg.current.qmpstatus || '').toLowerCase(); + const ok = /running|online|started/.test(st); + badgeCell.innerHTML = ok ? badge('running','ok') : + (/stopp|shutdown|offline/.test(st) ? badge('stopped','dark') : badge(st||'—','info')); + } else if (msg.type === 'task-start' && msg.upid && msg.node) { + // automatycznie podłącz tail do nowo wykrytego taska + openTaskWS(msg.upid, msg.node); + } else if (msg.type === 'task' && msg.upid && msg.status) { + // szybkie mrugnięcie statusem + const stopped = String(msg.status||'').toLowerCase() === 'stopped'; + if (stopped && typeof msg.exitstatus !== 'undefined') { + const ok = String(msg.exitstatus||'').toUpperCase() === 'OK'; + badgeCell.innerHTML = ok ? badge('running','ok') : badge('error','err'); + } else { + badgeCell.innerHTML = badge('working','info'); + } + } else if (msg.type === 'done' && msg.upid) { + // koniec zewnętrznego zadania (bez naszego taila) + if (msg.ok) badgeCell.innerHTML = badge('running','ok'); + else badgeCell.innerHTML = badge('error','err'); + try { document.getElementById('btnRefresh').click(); } catch {} + } + } catch {} + }; + wsObs.onclose = () => { wsObs = null; }; + wsObs.onerror = () => {}; }; + + const doAction = async (action, withTarget=false) => { + setRowBusy(true); + try { + const target = withTarget ? getTarget() : undefined; + const resp = await api.vmAction(sid, action, target); + if (!resp.ok) throw new Error(resp.error || 'unknown'); + // unlock – brak UPID + if (!resp.upid) { + showToast('Success', `${action} executed for ${sid}`, 'success'); + setRowBusy(false); toggleSub(false); + try { document.getElementById('btnRefresh').click(); } catch {} + return; + } + // UPID → WS tail + openTaskWS(resp.upid, resp.source_node); + } catch (e) { + showToast('Error', 'ERROR: ' + (e.message || e), 'danger'); + setRowBusy(false); + } + }; + + tr.querySelector('.act-unlock')?.addEventListener('click', () => doAction('unlock')); + tr.querySelector('.act-start')?.addEventListener('click', () => { toggleSub(true); doAction('start'); }); + tr.querySelector('.act-stop')?.addEventListener('click', () => { toggleSub(true); doAction('stop'); }); + tr.querySelector('.act-shutdown')?.addEventListener('click', () => { toggleSub(true); doAction('shutdown'); }); + tr.querySelector('.act-migrate')?.addEventListener('click', () => { toggleSub(true); doAction('migrate', true); }); + + // Status – pokaz/ukryj subpanel (bez WS) + tr.querySelector('.act-status')?.addEventListener('click', () => toggleSub(sub.classList.contains('d-none'))); + + // NEW: Watch 🔔 – włącz/wyłącz broadcast observe + const watchBtn = tr.querySelector('.act-watch'); + if (watchBtn) { + watchBtn.addEventListener('click', () => { + if (wsObs) { + closeWS(); + watchBtn.classList.remove('btn-info'); watchBtn.classList.add('btn-outline-info'); + watchBtn.textContent = 'Watch 🔔'; + } else { + openObserveWS(); + watchBtn.classList.remove('btn-outline-info'); watchBtn.classList.add('btn-info'); + watchBtn.textContent = 'Watching 🔔'; + } + }); + } + }); } diff --git a/static/js/api.js b/static/js/api.js index 3fbffa3..1cae57f 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -18,6 +18,16 @@ export const api = { const body = { sid, action }; if (target) body.target = target; return fetch('/api/vm-action', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}).then(r=>r.json()); }, - taskStatus: (upid, node) => fetch(`/api/task-status?upid=${encodeURIComponent(upid)}&node=${encodeURIComponent(node)}`).then(r=>r.json()), - taskLog: (upid, node, start=0) => fetch(`/api/task-log?upid=${encodeURIComponent(upid)}&node=${encodeURIComponent(node)}&start=${start}`).then(r=>r.json()) + + wsTaskURL: (upid, node) => { + const proto = (location.protocol === 'https:') ? 'wss' : 'ws'; + return `${proto}://${location.host}/ws/task?upid=${encodeURIComponent(upid)}&node=${encodeURIComponent(node)}`; + }, + // NEW: + wsObserveURL: (sid) => { + const proto = (location.protocol === 'https:') ? 'wss' : 'ws'; + return `${proto}://${location.host}/ws/observe?sid=${encodeURIComponent(sid)}`; + } + + }; From d18f8fcb8e54e6a3ceb6961b22df678d32b780a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 21:19:03 +0200 Subject: [PATCH 03/28] refator_comm1 --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index e4a286c..ca3908d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ flask gunicorn +flask-sock \ No newline at end of file From 3a90a481091eb34917da72381f3d484dddcbdfd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 21:19:50 +0200 Subject: [PATCH 04/28] refator_comm1 --- app.py | 39 --------------------------------------- 1 file changed, 39 deletions(-) diff --git a/app.py b/app.py index ce95e6b..0b68841 100644 --- a/app.py +++ b/app.py @@ -583,45 +583,6 @@ def ws_task(ws): except Exception: pass -# ---------------- WebSocket: live tail zadań ---------------- -@sock.route("/ws/task") -def ws_task(ws): - # (bez zmian – jak w mojej poprzedniej wersji) - q = ws.environ.get("QUERY_STRING", "") - params = {} - for part in q.split("&"): - if not part: continue - k, _, v = part.partition("=") - params[k] = v - upid = params.get("upid", "").strip() - node = params.get("node", "").strip() - if not upid or not node: - ws.send(json.dumps({"type":"error","error":"upid and node are required"})) - return - start = 0 - try: - while True: - st = get_json(["pvesh", "get", f"/nodes/{node}/tasks/{upid}/status"]) or {} - ws.send(json.dumps({"type":"status","status":st})) - lines = get_json(["pvesh", "get", f"/nodes/{node}/tasks/{upid}/log", "-start", str(start)]) or [] - if isinstance(lines, list) and lines: - for ln in lines: - txt = (ln.get("t") if isinstance(ln, dict) else None) - if txt: - ws.send(json.dumps({"type":"log","line":txt})) - try: - start = max((int(x.get("n", start)) for x in lines if isinstance(x, dict)), default=start) + 1 - except Exception: - pass - if isinstance(st, dict) and (str(st.get("status","")).lower() == "stopped" or st.get("exitstatus")): - ok = (str(st.get("exitstatus","")).upper() == "OK") - ws.send(json.dumps({"type":"done","ok":ok,"exitstatus":st.get("exitstatus")})) - break - time.sleep(1.2) - except Exception: - try: ws.close() - except Exception: pass - # ---------------- WebSocket: broadcast observe per sid ---------------- @sock.route("/ws/observe") def ws_observe(ws): From e3b3ff235bbdfdb176248ab116d7f476e449ec0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 21:28:42 +0200 Subject: [PATCH 05/28] refator_comm1 --- app.py | 64 +++++++++++++++++++++++++++------------------- static/js/admin.js | 49 ++++++++++++++++++++++------------- 2 files changed, 68 insertions(+), 45 deletions(-) diff --git a/app.py b/app.py index 0b68841..fe77d50 100644 --- a/app.py +++ b/app.py @@ -586,21 +586,22 @@ def ws_task(ws): # ---------------- WebSocket: broadcast observe per sid ---------------- @sock.route("/ws/observe") def ws_observe(ws): - """ - Streamuje zmiany stanu VM/CT i aktywne taski dla danego SID. - Query: sid=vm:101 (albo ct:123) - """ q = ws.environ.get("QUERY_STRING", "") params = {} for part in q.split("&"): if not part: continue k, _, v = part.partition("="); params[k] = v - sid = (params.get("sid") or "").strip() + sid_raw = (params.get("sid") or "").strip() + sid = norm_sid(sid_raw) if not sid: ws.send(json.dumps({"type":"error","error":"sid required"})); return - meta = cluster_vmct_meta() - tup = sid_to_tuple(sid, meta) + # Resolve tuple + node + def resolve_tuple() -> Optional[Tuple[str,int,str]]: + meta = cluster_vmct_meta() + return sid_to_tuple(sid, meta) + + tup = resolve_tuple() if not tup: ws.send(json.dumps({"type":"error","error":"unknown sid"})); return typ, vmid, node = tup @@ -609,35 +610,44 @@ def ws_observe(ws): last_hash = None seen_upids = set() + prev_node = node + try: while True: + ntup = resolve_tuple() + if ntup: + _, _, cur_node = ntup + if cur_node and cur_node != node: + ws.send(json.dumps({"type":"moved","old_node":node,"new_node":cur_node,"meta":{"sid":sid,"vmid":vmid,"typ":typ}})) + prev_node, node = node, cur_node + base = f"/nodes/{node}/{typ}/{vmid}" cur = get_json(["pvesh", "get", f"{base}/status/current"]) or {} - # wyślij tylko gdy zmiana cur_hash = json.dumps(cur, sort_keys=True) if cur_hash != last_hash: last_hash = cur_hash - ws.send(json.dumps({"type":"vm","current":cur,"meta":{"sid":norm_sid(sid),"node":node,"typ":typ,"vmid":vmid}})) + ws.send(json.dumps({"type":"vm","current":cur,"meta":{"sid":sid,"node":node,"typ":typ,"vmid":vmid}})) - tasks = get_json(["pvesh","get",f"/nodes/{node}/tasks","-limit","50"]) or [] - for t in tasks: - upid = t.get("upid") or t.get("pstart") # upid musi być stringiem - tid = (t.get("id") or "") # np. "qemu/101" lub "lxc/123" - if not isinstance(upid, str): continue - if (str(vmid) in tid) or (f"{'qemu' if typ=='qemu' else 'lxc'}/{vmid}" in tid): - # status szczegółowy - st = get_json(["pvesh","get",f"/nodes/{node}/tasks/{upid}/status"]) or {} - ev = {"type":"task","upid":upid,"status":st.get("status"),"exitstatus":st.get("exitstatus"),"node":node} - ws.send(json.dumps(ev)) - # nowy "running" task → ogłoś raz - if upid not in seen_upids and (str(st.get("status","")).lower() != "stopped"): - seen_upids.add(upid) - ws.send(json.dumps({"type":"task-start","upid":upid,"node":node})) - # zakończenie - if str(st.get("status","")).lower() == "stopped" or st.get("exitstatus"): - ws.send(json.dumps({"type":"done","upid":upid,"ok":str(st.get('exitstatus','')).upper()=='OK'})) + nodes_to_scan = [node] + ([prev_node] if prev_node and prev_node != node else []) + for nX in nodes_to_scan: + tasks = get_json(["pvesh","get",f"/nodes/{nX}/tasks","-limit","50"]) or [] + for t in tasks: + upid = t.get("upid") if isinstance(t, dict) else None + tid = (t.get("id") or "") if isinstance(t, dict) else "" + if not upid or not isinstance(upid, str): continue + # dopasuj po vmid lub ciągu qemu/|lxc/ + if (str(vmid) in tid) or (f"{'qemu' if typ=='qemu' else 'lxc'}/{vmid}" in tid): + st = get_json(["pvesh","get",f"/nodes/{nX}/tasks/{upid}/status"]) or {} + ws.send(json.dumps({"type":"task","upid":upid,"status":st.get("status"),"exitstatus":st.get("exitstatus"),"node":nX})) + # nowy running + if upid not in seen_upids and str(st.get("status","")).lower() != "stopped": + seen_upids.add(upid) + ws.send(json.dumps({"type":"task-start","upid":upid,"node":nX})) + # zakończone + if str(st.get("status","")).lower() == "stopped" or st.get("exitstatus"): + ws.send(json.dumps({"type":"done","upid":upid,"ok":str(st.get('exitstatus','')).upper()=='OK',"node":nX})) - time.sleep(2.0) + time.sleep(1.8) except Exception: try: ws.close() except Exception: pass diff --git a/static/js/admin.js b/static/js/admin.js index 7ca0f57..3fd7dd7 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -34,7 +34,8 @@ export async function renderVMAdmin() { Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { const sid = tr.getAttribute('data-sid'); const colSpan = tr.children.length; - const badgeCell = tr.children[4]; + const nodeCell = tr.children[3]; // Node + const badgeCell = tr.children[4]; // Status // subpanel (log) let sub = tr.nextElementSibling; @@ -50,7 +51,11 @@ export async function renderVMAdmin() { let wsObs = null; // observe websocket let wsTask = null; // tail websocket (auto z observe) - const closeWS = () => { try { wsObs && wsObs.close(); } catch {} try { wsTask && wsTask.close(); } catch {} wsObs = wsTask = null; }; + const closeWS = () => { + try { wsObs && wsObs.close(); } catch {} + try { wsTask && wsTask.close(); } catch {} + wsObs = wsTask = null; + }; const setRowBusy = (busy) => { const nameCell = tr.children[2]; @@ -100,30 +105,41 @@ export async function renderVMAdmin() { wsObs.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); + if (msg.type === 'vm' && msg.current) { - // aktualizuj badge na podstawie bieżącego statusu const st = String(msg.current.status || msg.current.qmpstatus || '').toLowerCase(); const ok = /running|online|started/.test(st); - badgeCell.innerHTML = ok ? badge('running','ok') : - (/stopp|shutdown|offline/.test(st) ? badge('stopped','dark') : badge(st||'—','info')); - } else if (msg.type === 'task-start' && msg.upid && msg.node) { - // automatycznie podłącz tail do nowo wykrytego taska + if (badgeCell) { + badgeCell.innerHTML = ok ? badge('running','ok') : + (/stopp|shutdown|offline/.test(st) ? badge('stopped','dark') : badge(st||'—','info')); + } + } + + else if (msg.type === 'task-start' && msg.upid && msg.node) { openTaskWS(msg.upid, msg.node); - } else if (msg.type === 'task' && msg.upid && msg.status) { - // szybkie mrugnięcie statusem + } + + else if (msg.type === 'task' && msg.upid && msg.status) { const stopped = String(msg.status||'').toLowerCase() === 'stopped'; if (stopped && typeof msg.exitstatus !== 'undefined') { const ok = String(msg.exitstatus||'').toUpperCase() === 'OK'; - badgeCell.innerHTML = ok ? badge('running','ok') : badge('error','err'); + if (badgeCell) badgeCell.innerHTML = ok ? badge('running','ok') : badge('error','err'); } else { - badgeCell.innerHTML = badge('working','info'); + if (badgeCell) badgeCell.innerHTML = badge('working','info'); } - } else if (msg.type === 'done' && msg.upid) { - // koniec zewnętrznego zadania (bez naszego taila) - if (msg.ok) badgeCell.innerHTML = badge('running','ok'); - else badgeCell.innerHTML = badge('error','err'); + } + + else if (msg.type === 'moved' && msg.new_node) { + if (nodeCell) nodeCell.textContent = msg.new_node; try { document.getElementById('btnRefresh').click(); } catch {} } + + else if (msg.type === 'done' && msg.upid) { + if (typeof msg.ok === 'boolean') { + if (badgeCell) badgeCell.innerHTML = msg.ok ? badge('running','ok') : badge('error','err'); + } + } + } catch {} }; wsObs.onclose = () => { wsObs = null; }; @@ -157,10 +173,8 @@ export async function renderVMAdmin() { tr.querySelector('.act-shutdown')?.addEventListener('click', () => { toggleSub(true); doAction('shutdown'); }); tr.querySelector('.act-migrate')?.addEventListener('click', () => { toggleSub(true); doAction('migrate', true); }); - // Status – pokaz/ukryj subpanel (bez WS) tr.querySelector('.act-status')?.addEventListener('click', () => toggleSub(sub.classList.contains('d-none'))); - // NEW: Watch 🔔 – włącz/wyłącz broadcast observe const watchBtn = tr.querySelector('.act-watch'); if (watchBtn) { watchBtn.addEventListener('click', () => { @@ -175,6 +189,5 @@ export async function renderVMAdmin() { } }); } - }); } From 39c783c40371979302175deb751c022215c893ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 21:41:24 +0200 Subject: [PATCH 06/28] refator_comm1 --- static/js/admin.js | 86 ++++++++++++++++++++++++++++++---------------- 1 file changed, 57 insertions(+), 29 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index 3fd7dd7..bc8985e 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -4,7 +4,7 @@ import { api } from './api.js'; export async function renderVMAdmin() { const data = await api.listAllVmct(); const arr = Array.isArray(data.all) ? data.all : []; - const nodes = Array.isArray(data.nodes) ? data.nodes : []; + const availableNodes = Array.isArray(data.nodes) ? data.nodes : []; const tbody = document.querySelector('#vm-admin tbody'); if (!arr.length) { setRows(tbody, [rowHTML(['Brak VM/CT'])]); return; } @@ -18,8 +18,8 @@ export async function renderVMAdmin() { `; - const sel = ` + ${availableNodes.map(n => ``).join('')} `; const tools = `
@@ -34,8 +34,10 @@ export async function renderVMAdmin() { Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { const sid = tr.getAttribute('data-sid'); const colSpan = tr.children.length; - const nodeCell = tr.children[3]; // Node - const badgeCell = tr.children[4]; // Status + const nodeCell = tr.children[3]; // kolumna Node + const badgeCell = tr.children[4]; // kolumna Status + const targetSel = tr.querySelector('.target-node'); + const watchBtn = tr.querySelector('.act-watch'); // subpanel (log) let sub = tr.nextElementSibling; @@ -49,7 +51,7 @@ export async function renderVMAdmin() { const toggleSub = (show) => sub.classList.toggle('d-none', !show); let wsObs = null; // observe websocket - let wsTask = null; // tail websocket (auto z observe) + let wsTask = null; // tail websocket const closeWS = () => { try { wsObs && wsObs.close(); } catch {} @@ -69,12 +71,26 @@ export async function renderVMAdmin() { if (!busy && spin) spin.remove(); }; - const getTarget = () => tr.querySelector('.target-node')?.value || ''; + const rebuildTargetSelect = (currentNode) => { + if (!targetSel) return; + const current = currentNode || (nodeCell?.textContent || '').trim(); + const html = availableNodes.map(n => + ``).join(''); + targetSel.innerHTML = html; + // jeśli selected jest disabled, ustaw zaznaczenie na pierwszy dozwolony + if (targetSel.options.length) { + const idx = Array.from(targetSel.options).findIndex(o => !o.disabled); + if (idx >= 0) targetSel.selectedIndex = idx; + } + }; + + const getTarget = () => targetSel?.value || ''; const openTaskWS = (upid, node) => { if (!upid) return; toggleSub(true); logPre.textContent = `UPID: ${upid} @ ${node}\n`; + try { wsTask && wsTask.close(); } catch {} wsTask = new WebSocket(api.wsTaskURL(upid, node)); wsTask.onmessage = (ev) => { try { @@ -84,13 +100,15 @@ export async function renderVMAdmin() { logPre.scrollTop = logPre.scrollHeight; } else if (msg.type === 'status' && msg.status) { const ok = String(msg.status.exitstatus||'').toUpperCase() === 'OK'; - const stopped = String(msg.status.status||'').toLowerCase() === 'stopped'; - if (badgeCell) badgeCell.innerHTML = ok ? badge('running','ok') : (stopped ? badge('stopped','dark') : badge('working','info')); + const s = String(msg.status.status||'').toLowerCase(); + if (badgeCell) badgeCell.innerHTML = ok ? badge('running','ok') : + (s === 'stopped' ? badge('stopped','dark') : badge('working','info')); } else if (msg.type === 'done') { const ok = !!msg.ok; if (badgeCell) badgeCell.innerHTML = ok ? badge('running','ok') : badge('error','err'); setRowBusy(false); - setTimeout(() => toggleSub(false), 1200); + // nie chowam subpanelu natychmiast, żeby dać doczytać log + setTimeout(() => toggleSub(false), 1500); try { document.getElementById('btnRefresh').click(); } catch {} } } catch {} @@ -98,10 +116,15 @@ export async function renderVMAdmin() { wsTask.onerror = () => {}; }; - const openObserveWS = () => { + const ensureWatchOn = () => { if (wsObs) return; const url = api.wsObserveURL(sid); wsObs = new WebSocket(url); + if (watchBtn) { + watchBtn.classList.remove('btn-outline-info'); + watchBtn.classList.add('btn-info'); + watchBtn.textContent = 'Watching 🔔'; + } wsObs.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); @@ -131,18 +154,17 @@ export async function renderVMAdmin() { else if (msg.type === 'moved' && msg.new_node) { if (nodeCell) nodeCell.textContent = msg.new_node; + rebuildTargetSelect(msg.new_node); try { document.getElementById('btnRefresh').click(); } catch {} } - else if (msg.type === 'done' && msg.upid) { - if (typeof msg.ok === 'boolean') { - if (badgeCell) badgeCell.innerHTML = msg.ok ? badge('running','ok') : badge('error','err'); - } + else if (msg.type === 'done' && typeof msg.ok === 'boolean') { + if (badgeCell) badgeCell.innerHTML = msg.ok ? badge('running','ok') : badge('error','err'); } } catch {} }; - wsObs.onclose = () => { wsObs = null; }; + wsObs.onclose = () => { wsObs = null; if (watchBtn) { watchBtn.classList.remove('btn-info'); watchBtn.classList.add('btn-outline-info'); watchBtn.textContent='Watch 🔔'; } }; wsObs.onerror = () => {}; }; @@ -150,32 +172,38 @@ export async function renderVMAdmin() { setRowBusy(true); try { const target = withTarget ? getTarget() : undefined; + + ensureWatchOn(); + if (action !== 'unlock') toggleSub(true); + const resp = await api.vmAction(sid, action, target); if (!resp.ok) throw new Error(resp.error || 'unknown'); - // unlock – brak UPID + if (!resp.upid) { - showToast('Success', `${action} executed for ${sid}`, 'success'); - setRowBusy(false); toggleSub(false); - try { document.getElementById('btnRefresh').click(); } catch {} + + logPre.textContent = `Waiting for task… (${action})\n`; + showToast('Info', `${action} zainicjowane`, 'info'); + setRowBusy(false); return; } - // UPID → WS tail + openTaskWS(resp.upid, resp.source_node); + } catch (e) { showToast('Error', 'ERROR: ' + (e.message || e), 'danger'); setRowBusy(false); + toggleSub(false); } }; tr.querySelector('.act-unlock')?.addEventListener('click', () => doAction('unlock')); - tr.querySelector('.act-start')?.addEventListener('click', () => { toggleSub(true); doAction('start'); }); - tr.querySelector('.act-stop')?.addEventListener('click', () => { toggleSub(true); doAction('stop'); }); - tr.querySelector('.act-shutdown')?.addEventListener('click', () => { toggleSub(true); doAction('shutdown'); }); - tr.querySelector('.act-migrate')?.addEventListener('click', () => { toggleSub(true); doAction('migrate', true); }); + tr.querySelector('.act-start')?.addEventListener('click', () => doAction('start')); + tr.querySelector('.act-stop')?.addEventListener('click', () => doAction('stop')); + tr.querySelector('.act-shutdown')?.addEventListener('click',() => doAction('shutdown')); + tr.querySelector('.act-migrate')?.addEventListener('click', () => doAction('migrate', true)); tr.querySelector('.act-status')?.addEventListener('click', () => toggleSub(sub.classList.contains('d-none'))); - const watchBtn = tr.querySelector('.act-watch'); if (watchBtn) { watchBtn.addEventListener('click', () => { if (wsObs) { @@ -183,11 +211,11 @@ export async function renderVMAdmin() { watchBtn.classList.remove('btn-info'); watchBtn.classList.add('btn-outline-info'); watchBtn.textContent = 'Watch 🔔'; } else { - openObserveWS(); - watchBtn.classList.remove('btn-outline-info'); watchBtn.classList.add('btn-info'); - watchBtn.textContent = 'Watching 🔔'; + ensureWatchOn(); } }); } + + rebuildTargetSelect(nodeCell?.textContent?.trim()); }); } From a48a10ed354e2386c41a28ee6b3b98aa8968e610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 21:47:39 +0200 Subject: [PATCH 07/28] refator_comm1 --- static/js/admin.js | 111 +++++++++++++++++++++++++++++++++------------ 1 file changed, 82 insertions(+), 29 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index bc8985e..a0997f6 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -1,7 +1,27 @@ import { badge, rowHTML, setRows, safe, showToast } from './helpers.js'; import { api } from './api.js'; +/** Globalny rejestr gniazd: sid -> { obs: WebSocket|null, task: WebSocket|null } */ +const liveSockets = new Map(); + +/** Pomocniczo: zamknij WS-y dla sid */ +function closeForSid(sid) { + const entry = liveSockets.get(sid); + if (!entry) return; + try { entry.obs && entry.obs.close(); } catch {} + try { entry.task && entry.task.close(); } catch {} + liveSockets.delete(sid); +} + +/** Exportowany helper do ew. sprzątania z zewnątrz (opcjonalnie) */ +export function stopAllAdminWatches() { + for (const sid of liveSockets.keys()) closeForSid(sid); +} + export async function renderVMAdmin() { + // zanim przebudujemy tabelę – zamknij WS-y (unikamy „sierotek”) + stopAllAdminWatches(); + const data = await api.listAllVmct(); const arr = Array.isArray(data.all) ? data.all : []; const availableNodes = Array.isArray(data.nodes) ? data.nodes : []; @@ -19,12 +39,12 @@ export async function renderVMAdmin() {
`; const sel = ``; const tools = `
- +
`; return rowHTML([sid, type.toUpperCase(), name, node, st, actions, sel, tools], `data-sid="${sid}"`); }); @@ -50,15 +70,6 @@ export async function renderVMAdmin() { const logPre = sub.querySelector('.mig-log'); const toggleSub = (show) => sub.classList.toggle('d-none', !show); - let wsObs = null; // observe websocket - let wsTask = null; // tail websocket - - const closeWS = () => { - try { wsObs && wsObs.close(); } catch {} - try { wsTask && wsTask.close(); } catch {} - wsObs = wsTask = null; - }; - const setRowBusy = (busy) => { const nameCell = tr.children[2]; let spin = nameCell.querySelector('.op-spin'); @@ -77,21 +88,26 @@ export async function renderVMAdmin() { const html = availableNodes.map(n => ``).join(''); targetSel.innerHTML = html; - // jeśli selected jest disabled, ustaw zaznaczenie na pierwszy dozwolony - if (targetSel.options.length) { - const idx = Array.from(targetSel.options).findIndex(o => !o.disabled); - if (idx >= 0) targetSel.selectedIndex = idx; - } + // ustaw focus na pierwszy dozwolony, jeśli selected jest disabled + const idx = Array.from(targetSel.options).findIndex(o => !o.disabled); + if (idx >= 0) targetSel.selectedIndex = idx; }; const getTarget = () => targetSel?.value || ''; + // ---- WS task tail (na bieżący UPID) ---- const openTaskWS = (upid, node) => { if (!upid) return; toggleSub(true); logPre.textContent = `UPID: ${upid} @ ${node}\n`; - try { wsTask && wsTask.close(); } catch {} - wsTask = new WebSocket(api.wsTaskURL(upid, node)); + + // zamknij ewentualnie poprzednie tail WS dla tej VM + const entry = liveSockets.get(sid) || {}; + try { entry.task && entry.task.close(); } catch {} + const wsTask = new WebSocket(api.wsTaskURL(upid, node)); + entry.task = wsTask; + liveSockets.set(sid, entry); + wsTask.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); @@ -107,29 +123,42 @@ export async function renderVMAdmin() { const ok = !!msg.ok; if (badgeCell) badgeCell.innerHTML = ok ? badge('running','ok') : badge('error','err'); setRowBusy(false); - // nie chowam subpanelu natychmiast, żeby dać doczytać log setTimeout(() => toggleSub(false), 1500); try { document.getElementById('btnRefresh').click(); } catch {} } } catch {} }; wsTask.onerror = () => {}; + wsTask.onclose = () => { + // nie czyścimy entry.task, żeby móc rozróżnić zamknięcie „nasze” vs. serwera + }; }; + // ---- WS observe (domyślnie ON) ---- const ensureWatchOn = () => { - if (wsObs) return; - const url = api.wsObserveURL(sid); - wsObs = new WebSocket(url); + // jeśli już obserwujemy – nic nie rób + const existing = liveSockets.get(sid); + if (existing && existing.obs && existing.obs.readyState <= 1) return; + + // upewnij się, że stare gniazda zamknięte + closeForSid(sid); + + const wsObs = new WebSocket(api.wsObserveURL(sid)); + liveSockets.set(sid, { obs: wsObs, task: null }); + + // wizualnie: włączony if (watchBtn) { watchBtn.classList.remove('btn-outline-info'); watchBtn.classList.add('btn-info'); watchBtn.textContent = 'Watching 🔔'; } + wsObs.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); if (msg.type === 'vm' && msg.current) { + // live status (działa także dla start/stop/shutdown bez UPID) const st = String(msg.current.status || msg.current.qmpstatus || '').toLowerCase(); const ok = /running|online|started/.test(st); if (badgeCell) { @@ -139,6 +168,7 @@ export async function renderVMAdmin() { } else if (msg.type === 'task-start' && msg.upid && msg.node) { + // jeżeli akcja wystartowała spoza panelu lub bez UPID od API — podepnij log openTaskWS(msg.upid, msg.node); } @@ -153,6 +183,7 @@ export async function renderVMAdmin() { } else if (msg.type === 'moved' && msg.new_node) { + // VM przeniesiona – od razu popraw wiersz if (nodeCell) nodeCell.textContent = msg.new_node; rebuildTargetSelect(msg.new_node); try { document.getElementById('btnRefresh').click(); } catch {} @@ -164,7 +195,19 @@ export async function renderVMAdmin() { } catch {} }; - wsObs.onclose = () => { wsObs = null; if (watchBtn) { watchBtn.classList.remove('btn-info'); watchBtn.classList.add('btn-outline-info'); watchBtn.textContent='Watch 🔔'; } }; + + wsObs.onclose = () => { + const e = liveSockets.get(sid); + if (e && e.obs === wsObs) { + // oznacz jako wyłączone + liveSockets.set(sid, { obs: null, task: e.task || null }); + if (watchBtn) { + watchBtn.classList.remove('btn-info'); + watchBtn.classList.add('btn-outline-info'); + watchBtn.textContent = 'Watch 🔔'; + } + } + }; wsObs.onerror = () => {}; }; @@ -173,20 +216,22 @@ export async function renderVMAdmin() { try { const target = withTarget ? getTarget() : undefined; + // ZAWSZE live – zapewnia status/log nawet jeśli API nie odda UPID ensureWatchOn(); - if (action !== 'unlock') toggleSub(true); + if (action !== 'unlock') toggleSub(true); // pokaż subpanel logów dla akcji z zadaniami const resp = await api.vmAction(sid, action, target); if (!resp.ok) throw new Error(resp.error || 'unknown'); if (!resp.upid) { - + // np. unlock albo środowisko nie oddało UPID – poczekaj na task-start z observe logPre.textContent = `Waiting for task… (${action})\n`; showToast('Info', `${action} zainicjowane`, 'info'); - setRowBusy(false); + setRowBusy(false); // spinner wyłącz, status/log dojadą z observe return; } + // mamy UPID – od razu podłącz tail openTaskWS(resp.upid, resp.source_node); } catch (e) { @@ -196,18 +241,22 @@ export async function renderVMAdmin() { } }; + // Akcje tr.querySelector('.act-unlock')?.addEventListener('click', () => doAction('unlock')); tr.querySelector('.act-start')?.addEventListener('click', () => doAction('start')); tr.querySelector('.act-stop')?.addEventListener('click', () => doAction('stop')); tr.querySelector('.act-shutdown')?.addEventListener('click',() => doAction('shutdown')); tr.querySelector('.act-migrate')?.addEventListener('click', () => doAction('migrate', true)); + // Status – pokaż/ukryj subpanel (bez WS) tr.querySelector('.act-status')?.addEventListener('click', () => toggleSub(sub.classList.contains('d-none'))); + // Watch 🔔 – manualny toggle (domyślnie jest ON) if (watchBtn) { watchBtn.addEventListener('click', () => { - if (wsObs) { - closeWS(); + const e = liveSockets.get(sid); + if (e && e.obs) { + closeForSid(sid); watchBtn.classList.remove('btn-info'); watchBtn.classList.add('btn-outline-info'); watchBtn.textContent = 'Watch 🔔'; } else { @@ -216,6 +265,10 @@ export async function renderVMAdmin() { }); } - rebuildTargetSelect(nodeCell?.textContent?.trim()); + // startowo: LIVE bez klikania + ensureWatchOn(); }); + + // sprzątanie przy zamknięciu karty + window.addEventListener('beforeunload', stopAllAdminWatches, { once: true }); } From 6bb5e2715f4c30a93f1ebc34606f78e30e14e73c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 21:59:24 +0200 Subject: [PATCH 08/28] refator_comm1 --- static/js/admin.js | 145 ++++++++++++++++++++----------------------- static/js/api.js | 1 - templates/index.html | 3 +- 3 files changed, 69 insertions(+), 80 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index a0997f6..5ac9001 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -1,10 +1,9 @@ import { badge, rowHTML, setRows, safe, showToast } from './helpers.js'; import { api } from './api.js'; -/** Globalny rejestr gniazd: sid -> { obs: WebSocket|null, task: WebSocket|null } */ const liveSockets = new Map(); +let reconcileTimer = null; -/** Pomocniczo: zamknij WS-y dla sid */ function closeForSid(sid) { const entry = liveSockets.get(sid); if (!entry) return; @@ -13,13 +12,17 @@ function closeForSid(sid) { liveSockets.delete(sid); } -/** Exportowany helper do ew. sprzątania z zewnątrz (opcjonalnie) */ export function stopAllAdminWatches() { - for (const sid of liveSockets.keys()) closeForSid(sid); + if (reconcileTimer) { clearInterval(reconcileTimer); reconcileTimer = null; } + for (const sid of Array.from(liveSockets.keys())) closeForSid(sid); +} + +function setBadge(cell, val) { + if (!cell) return; + cell.innerHTML = val; } export async function renderVMAdmin() { - // zanim przebudujemy tabelę – zamknij WS-y (unikamy „sierotek”) stopAllAdminWatches(); const data = await api.listAllVmct(); @@ -44,7 +47,6 @@ export async function renderVMAdmin() { const tools = `
-
`; return rowHTML([sid, type.toUpperCase(), name, node, st, actions, sel, tools], `data-sid="${sid}"`); }); @@ -54,12 +56,10 @@ export async function renderVMAdmin() { Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { const sid = tr.getAttribute('data-sid'); const colSpan = tr.children.length; - const nodeCell = tr.children[3]; // kolumna Node - const badgeCell = tr.children[4]; // kolumna Status + const nodeCell = tr.children[3]; + const badgeCell = tr.children[4]; const targetSel = tr.querySelector('.target-node'); - const watchBtn = tr.querySelector('.act-watch'); - // subpanel (log) let sub = tr.nextElementSibling; if (!sub || !sub.classList.contains('mig-row')) { sub = document.createElement('tr'); sub.className = 'mig-row d-none'; @@ -88,20 +88,17 @@ export async function renderVMAdmin() { const html = availableNodes.map(n => ``).join(''); targetSel.innerHTML = html; - // ustaw focus na pierwszy dozwolony, jeśli selected jest disabled const idx = Array.from(targetSel.options).findIndex(o => !o.disabled); if (idx >= 0) targetSel.selectedIndex = idx; }; const getTarget = () => targetSel?.value || ''; - // ---- WS task tail (na bieżący UPID) ---- const openTaskWS = (upid, node) => { if (!upid) return; toggleSub(true); logPre.textContent = `UPID: ${upid} @ ${node}\n`; - // zamknij ewentualnie poprzednie tail WS dla tej VM const entry = liveSockets.get(sid) || {}; try { entry.task && entry.task.close(); } catch {} const wsTask = new WebSocket(api.wsTaskURL(upid, node)); @@ -117,80 +114,55 @@ export async function renderVMAdmin() { } else if (msg.type === 'status' && msg.status) { const ok = String(msg.status.exitstatus||'').toUpperCase() === 'OK'; const s = String(msg.status.status||'').toLowerCase(); - if (badgeCell) badgeCell.innerHTML = ok ? badge('running','ok') : - (s === 'stopped' ? badge('stopped','dark') : badge('working','info')); + setBadge(badgeCell, ok ? badge('running','ok') : + (s === 'stopped' ? badge('stopped','dark') : badge('working','info'))); } else if (msg.type === 'done') { const ok = !!msg.ok; - if (badgeCell) badgeCell.innerHTML = ok ? badge('running','ok') : badge('error','err'); + setBadge(badgeCell, ok ? badge('running','ok') : badge('error','err')); setRowBusy(false); setTimeout(() => toggleSub(false), 1500); - try { document.getElementById('btnRefresh').click(); } catch {} } } catch {} }; - wsTask.onerror = () => {}; - wsTask.onclose = () => { - // nie czyścimy entry.task, żeby móc rozróżnić zamknięcie „nasze” vs. serwera - }; }; - // ---- WS observe (domyślnie ON) ---- const ensureWatchOn = () => { - // jeśli już obserwujemy – nic nie rób const existing = liveSockets.get(sid); if (existing && existing.obs && existing.obs.readyState <= 1) return; - // upewnij się, że stare gniazda zamknięte closeForSid(sid); const wsObs = new WebSocket(api.wsObserveURL(sid)); liveSockets.set(sid, { obs: wsObs, task: null }); - // wizualnie: włączony - if (watchBtn) { - watchBtn.classList.remove('btn-outline-info'); - watchBtn.classList.add('btn-info'); - watchBtn.textContent = 'Watching 🔔'; - } - wsObs.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); if (msg.type === 'vm' && msg.current) { - // live status (działa także dla start/stop/shutdown bez UPID) const st = String(msg.current.status || msg.current.qmpstatus || '').toLowerCase(); const ok = /running|online|started/.test(st); - if (badgeCell) { - badgeCell.innerHTML = ok ? badge('running','ok') : - (/stopp|shutdown|offline/.test(st) ? badge('stopped','dark') : badge(st||'—','info')); - } + setBadge(badgeCell, ok ? badge('running','ok') : + (/stopp|shutdown|offline/.test(st) ? badge('stopped','dark') : badge(st||'—','info'))); } - else if (msg.type === 'task-start' && msg.upid && msg.node) { - // jeżeli akcja wystartowała spoza panelu lub bez UPID od API — podepnij log openTaskWS(msg.upid, msg.node); } - else if (msg.type === 'task' && msg.upid && msg.status) { const stopped = String(msg.status||'').toLowerCase() === 'stopped'; if (stopped && typeof msg.exitstatus !== 'undefined') { const ok = String(msg.exitstatus||'').toUpperCase() === 'OK'; - if (badgeCell) badgeCell.innerHTML = ok ? badge('running','ok') : badge('error','err'); + setBadge(badgeCell, ok ? badge('running','ok') : badge('error','err')); } else { - if (badgeCell) badgeCell.innerHTML = badge('working','info'); + setBadge(badgeCell, badge('working','info')); } } - else if (msg.type === 'moved' && msg.new_node) { - // VM przeniesiona – od razu popraw wiersz if (nodeCell) nodeCell.textContent = msg.new_node; rebuildTargetSelect(msg.new_node); - try { document.getElementById('btnRefresh').click(); } catch {} } - else if (msg.type === 'done' && typeof msg.ok === 'boolean') { - if (badgeCell) badgeCell.innerHTML = msg.ok ? badge('running','ok') : badge('error','err'); + setBadge(badgeCell, msg.ok ? badge('running','ok') : badge('error','err')); } } catch {} @@ -199,16 +171,9 @@ export async function renderVMAdmin() { wsObs.onclose = () => { const e = liveSockets.get(sid); if (e && e.obs === wsObs) { - // oznacz jako wyłączone liveSockets.set(sid, { obs: null, task: e.task || null }); - if (watchBtn) { - watchBtn.classList.remove('btn-info'); - watchBtn.classList.add('btn-outline-info'); - watchBtn.textContent = 'Watch 🔔'; - } } }; - wsObs.onerror = () => {}; }; const doAction = async (action, withTarget=false) => { @@ -216,22 +181,21 @@ export async function renderVMAdmin() { try { const target = withTarget ? getTarget() : undefined; - // ZAWSZE live – zapewnia status/log nawet jeśli API nie odda UPID ensureWatchOn(); - if (action !== 'unlock') toggleSub(true); // pokaż subpanel logów dla akcji z zadaniami + if (action !== 'unlock') { + setBadge(badgeCell, badge('working','info')); + toggleSub(true); + } const resp = await api.vmAction(sid, action, target); if (!resp.ok) throw new Error(resp.error || 'unknown'); if (!resp.upid) { - // np. unlock albo środowisko nie oddało UPID – poczekaj na task-start z observe logPre.textContent = `Waiting for task… (${action})\n`; - showToast('Info', `${action} zainicjowane`, 'info'); - setRowBusy(false); // spinner wyłącz, status/log dojadą z observe + setRowBusy(false); return; } - // mamy UPID – od razu podłącz tail openTaskWS(resp.upid, resp.source_node); } catch (e) { @@ -241,34 +205,61 @@ export async function renderVMAdmin() { } }; - // Akcje tr.querySelector('.act-unlock')?.addEventListener('click', () => doAction('unlock')); tr.querySelector('.act-start')?.addEventListener('click', () => doAction('start')); tr.querySelector('.act-stop')?.addEventListener('click', () => doAction('stop')); tr.querySelector('.act-shutdown')?.addEventListener('click',() => doAction('shutdown')); tr.querySelector('.act-migrate')?.addEventListener('click', () => doAction('migrate', true)); - // Status – pokaż/ukryj subpanel (bez WS) tr.querySelector('.act-status')?.addEventListener('click', () => toggleSub(sub.classList.contains('d-none'))); - // Watch 🔔 – manualny toggle (domyślnie jest ON) - if (watchBtn) { - watchBtn.addEventListener('click', () => { - const e = liveSockets.get(sid); - if (e && e.obs) { - closeForSid(sid); - watchBtn.classList.remove('btn-info'); watchBtn.classList.add('btn-outline-info'); - watchBtn.textContent = 'Watch 🔔'; - } else { - ensureWatchOn(); - } - }); - } - - // startowo: LIVE bez klikania ensureWatchOn(); }); - // sprzątanie przy zamknięciu karty + reconcileTimer = setInterval(async () => { + try { + const latest = await api.listAllVmct(); + const all = Array.isArray(latest.all) ? latest.all : []; + const bySid = new Map(all.map(x => [String(x.sid), x])); + + Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { + const sid = tr.getAttribute('data-sid'); + const rowData = bySid.get(sid); + if (!rowData) return; + + const nodeCell = tr.children[3]; + const badgeCell = tr.children[4]; + const targetSel = tr.querySelector('.target-node'); + + const newNode = String(rowData.node || '').trim(); + if (nodeCell && newNode && nodeCell.textContent.trim() !== newNode) { + nodeCell.textContent = newNode; + if (targetSel) { + const options = Array.from(targetSel.options); + options.forEach(o => { o.disabled = (o.value === newNode); o.selected = (o.value === newNode); }); + const idx = options.findIndex(o => !o.disabled); + if (idx >= 0) targetSel.selectedIndex = idx; + } + } + + const running = /running/i.test(rowData.status || ''); + const currentBadge = badgeCell?.innerText?.toLowerCase() || ''; + const isWorking = currentBadge.includes('working'); + if (badgeCell && !isWorking) { + setBadge(badgeCell, running ? badge('running','ok') : badge(rowData.status || '—','dark')); + } + + const existing = liveSockets.get(sid); + if (!(existing && existing.obs && existing.obs.readyState <= 1)) { + const wsObs = new WebSocket(api.wsObserveURL(sid)); + liveSockets.set(sid, { obs: wsObs, task: existing?.task || null }); + wsObs.onmessage = () => {}; + wsObs.onerror = () => {}; + } + }); + + } catch {} + }, 3000); + window.addEventListener('beforeunload', stopAllAdminWatches, { once: true }); } diff --git a/static/js/api.js b/static/js/api.js index 1cae57f..86bac63 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -23,7 +23,6 @@ export const api = { const proto = (location.protocol === 'https:') ? 'wss' : 'ws'; return `${proto}://${location.host}/ws/task?upid=${encodeURIComponent(upid)}&node=${encodeURIComponent(node)}`; }, - // NEW: wsObserveURL: (sid) => { const proto = (location.protocol === 'https:') ? 'wss' : 'ws'; return `${proto}://${location.host}/ws/observe?sid=${encodeURIComponent(sid)}`; diff --git a/templates/index.html b/templates/index.html index b27eb29..9bb025c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -227,8 +227,7 @@ -
Akcje: Unlock (qm), Start/Stop/Shutdown, Offline migrate. Postęp widoczny na - żywo per wiersz.
+
Akcje: Unlock (qm), Start/Stop/Shutdown, Offline migrate.
From 5b0431fbec8baeed7e2c514d594958c1a91d5d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 22:13:35 +0200 Subject: [PATCH 09/28] refator_comm1 --- static/js/admin.js | 127 ++++++++++++++++++++++++++++++--------------- 1 file changed, 85 insertions(+), 42 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index 5ac9001..7090887 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -2,7 +2,9 @@ import { badge, rowHTML, setRows, safe, showToast } from './helpers.js'; import { api } from './api.js'; const liveSockets = new Map(); -let reconcileTimer = null; +let slowTimer = null; +let fastTimer = null; +const activeSids = new Set(); function closeForSid(sid) { const entry = liveSockets.get(sid); @@ -13,7 +15,9 @@ function closeForSid(sid) { } export function stopAllAdminWatches() { - if (reconcileTimer) { clearInterval(reconcileTimer); reconcileTimer = null; } + if (slowTimer) { clearInterval(slowTimer); slowTimer = null; } + if (fastTimer) { clearInterval(fastTimer); fastTimer = null; } + activeSids.clear(); for (const sid of Array.from(liveSockets.keys())) closeForSid(sid); } @@ -22,6 +26,21 @@ function setBadge(cell, val) { cell.innerHTML = val; } +function setMigrateDisabled(tr, disabled) { + const btn = tr.querySelector('.act-migrate'); + if (btn) btn.disabled = !!disabled; +} + +function rebuildTargetSelect(selectEl, currentNode, nodes) { + if (!selectEl) return; + const html = nodes.map(n => + `` + ).join(''); + selectEl.innerHTML = html; + const idx = Array.from(selectEl.options).findIndex(o => !o.disabled); + if (idx >= 0) selectEl.selectedIndex = idx; +} + export async function renderVMAdmin() { stopAllAdminWatches(); @@ -33,7 +52,7 @@ export async function renderVMAdmin() { 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 st = /running/i.test(x.status || '') ? badge('running','ok') : badge(x.status || '—','dark'); const actions = `
@@ -82,16 +101,6 @@ export async function renderVMAdmin() { if (!busy && spin) spin.remove(); }; - const rebuildTargetSelect = (currentNode) => { - if (!targetSel) return; - const current = currentNode || (nodeCell?.textContent || '').trim(); - const html = availableNodes.map(n => - ``).join(''); - targetSel.innerHTML = html; - const idx = Array.from(targetSel.options).findIndex(o => !o.disabled); - if (idx >= 0) targetSel.selectedIndex = idx; - }; - const getTarget = () => targetSel?.value || ''; const openTaskWS = (upid, node) => { @@ -104,6 +113,7 @@ export async function renderVMAdmin() { const wsTask = new WebSocket(api.wsTaskURL(upid, node)); entry.task = wsTask; liveSockets.set(sid, entry); + activeSids.add(sid); wsTask.onmessage = (ev) => { try { @@ -120,7 +130,8 @@ export async function renderVMAdmin() { const ok = !!msg.ok; setBadge(badgeCell, ok ? badge('running','ok') : badge('error','err')); setRowBusy(false); - setTimeout(() => toggleSub(false), 1500); + setTimeout(() => toggleSub(false), 1000); + setTimeout(() => activeSids.delete(sid), 5000); } } catch {} }; @@ -141,30 +152,36 @@ export async function renderVMAdmin() { if (msg.type === 'vm' && msg.current) { const st = String(msg.current.status || msg.current.qmpstatus || '').toLowerCase(); - const ok = /running|online|started/.test(st); - setBadge(badgeCell, ok ? badge('running','ok') : + const isRunning = /running|online|started/.test(st); + setBadge(badgeCell, isRunning ? badge('running','ok') : (/stopp|shutdown|offline/.test(st) ? badge('stopped','dark') : badge(st||'—','info'))); + setMigrateDisabled(tr, isRunning); + if (!isRunning && st) activeSids.add(sid); } else if (msg.type === 'task-start' && msg.upid && msg.node) { openTaskWS(msg.upid, msg.node); + activeSids.add(sid); } else if (msg.type === 'task' && msg.upid && msg.status) { const stopped = String(msg.status||'').toLowerCase() === 'stopped'; if (stopped && typeof msg.exitstatus !== 'undefined') { const ok = String(msg.exitstatus||'').toUpperCase() === 'OK'; setBadge(badgeCell, ok ? badge('running','ok') : badge('error','err')); + setTimeout(() => activeSids.delete(sid), 5000); } else { setBadge(badgeCell, badge('working','info')); + activeSids.add(sid); } } else if (msg.type === 'moved' && msg.new_node) { if (nodeCell) nodeCell.textContent = msg.new_node; - rebuildTargetSelect(msg.new_node); + rebuildTargetSelect(targetSel, msg.new_node, Array.from(new Set([...(window.__nodesCache||[]), msg.new_node]))); + activeSids.add(sid); } else if (msg.type === 'done' && typeof msg.ok === 'boolean') { setBadge(badgeCell, msg.ok ? badge('running','ok') : badge('error','err')); + setTimeout(() => activeSids.delete(sid), 5000); } - } catch {} }; @@ -185,6 +202,7 @@ export async function renderVMAdmin() { if (action !== 'unlock') { setBadge(badgeCell, badge('working','info')); toggleSub(true); + activeSids.add(sid); } const resp = await api.vmAction(sid, action, target); @@ -193,6 +211,7 @@ export async function renderVMAdmin() { if (!resp.upid) { logPre.textContent = `Waiting for task… (${action})\n`; setRowBusy(false); + activeSids.add(sid); return; } @@ -202,6 +221,7 @@ export async function renderVMAdmin() { showToast('Error', 'ERROR: ' + (e.message || e), 'danger'); setRowBusy(false); toggleSub(false); + activeSids.delete(sid); } }; @@ -210,17 +230,23 @@ export async function renderVMAdmin() { tr.querySelector('.act-stop')?.addEventListener('click', () => doAction('stop')); tr.querySelector('.act-shutdown')?.addEventListener('click',() => doAction('shutdown')); tr.querySelector('.act-migrate')?.addEventListener('click', () => doAction('migrate', true)); - tr.querySelector('.act-status')?.addEventListener('click', () => toggleSub(sub.classList.contains('d-none'))); ensureWatchOn(); + + const initialRunning = /running/i.test(tr.querySelector('td:nth-child(5)')?.innerText || ''); + setMigrateDisabled(tr, initialRunning); }); - reconcileTimer = setInterval(async () => { + window.__nodesCache = availableNodes.slice(); + + slowTimer = setInterval(async () => { try { const latest = await api.listAllVmct(); const all = Array.isArray(latest.all) ? latest.all : []; const bySid = new Map(all.map(x => [String(x.sid), x])); + const nodesNow = Array.isArray(latest.nodes) ? latest.nodes : window.__nodesCache || []; + window.__nodesCache = nodesNow; Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { const sid = tr.getAttribute('data-sid'); @@ -234,32 +260,49 @@ export async function renderVMAdmin() { const newNode = String(rowData.node || '').trim(); if (nodeCell && newNode && nodeCell.textContent.trim() !== newNode) { nodeCell.textContent = newNode; - if (targetSel) { - const options = Array.from(targetSel.options); - options.forEach(o => { o.disabled = (o.value === newNode); o.selected = (o.value === newNode); }); - const idx = options.findIndex(o => !o.disabled); - if (idx >= 0) targetSel.selectedIndex = idx; - } + rebuildTargetSelect(targetSel, newNode, nodesNow); } - const running = /running/i.test(rowData.status || ''); - const currentBadge = badgeCell?.innerText?.toLowerCase() || ''; - const isWorking = currentBadge.includes('working'); - if (badgeCell && !isWorking) { - setBadge(badgeCell, running ? badge('running','ok') : badge(rowData.status || '—','dark')); - } - - const existing = liveSockets.get(sid); - if (!(existing && existing.obs && existing.obs.readyState <= 1)) { - const wsObs = new WebSocket(api.wsObserveURL(sid)); - liveSockets.set(sid, { obs: wsObs, task: existing?.task || null }); - wsObs.onmessage = () => {}; - wsObs.onerror = () => {}; - } + const isRunning = /running/i.test(rowData.status || ''); + setBadge(badgeCell, isRunning ? badge('running','ok') : badge(rowData.status || '—','dark')); + setMigrateDisabled(tr, isRunning); }); - } catch {} - }, 3000); + }, 30000); + + fastTimer = setInterval(async () => { + try { + const sids = Array.from(activeSids); + if (!sids.length) return; + for (const sid of sids) { + const detail = await api.vmDetail(sid); + if (!detail || !detail.meta) continue; + const tr = tbody.querySelector(`tr[data-sid="${sid}"]`); + if (!tr) continue; + const nodeCell = tr.children[3]; + const badgeCell = tr.children[4]; + const targetSel = tr.querySelector('.target-node'); + + const stRaw = String((detail.current && (detail.current.status || detail.current.qmpstatus)) || '').toLowerCase(); + const isRunning = /running|online|started/.test(stRaw); + setBadge(badgeCell, isRunning ? badge('running','ok') : + (/stopp|shutdown|offline/.test(stRaw) ? badge('stopped','dark') : badge(stRaw||'—','info'))); + setMigrateDisabled(tr, isRunning); + + const newNode = String(detail.node || (detail.meta && detail.meta.node) || '').trim(); + if (newNode) { + if (nodeCell && nodeCell.textContent.trim() !== newNode) { + nodeCell.textContent = newNode; + } + rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); + } + + if (stRaw && /running|stopped|shutdown/.test(stRaw)) { + setTimeout(() => activeSids.delete(sid), 5000); + } + } + } catch {} + }, 10000); window.addEventListener('beforeunload', stopAllAdminWatches, { once: true }); } From fda2b721b38ba56be555ac40e3631f2dda4ae070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 22:27:39 +0200 Subject: [PATCH 10/28] refator_comm1 --- app.py | 91 ++----------------- static/js/admin.js | 205 +++++++++++++------------------------------ templates/index.html | 4 +- 3 files changed, 67 insertions(+), 233 deletions(-) diff --git a/app.py b/app.py index fe77d50..7714d84 100644 --- a/app.py +++ b/app.py @@ -512,76 +512,6 @@ def api_vm_action(): except Exception as e: return jsonify(ok=False, error=str(e)), 500 -@app.get("/api/task-status") -def api_task_status(): - upid = request.args.get("upid", "").strip() - node = request.args.get("node", "").strip() - if not upid or not node: - return jsonify(ok=False, error="upid and node required"), 400 - st = get_json(["pvesh", "get", f"/nodes/{node}/tasks/{upid}/status"]) or {} - return jsonify(ok=True, status=st) - -@app.get("/api/task-log") -def api_task_log(): - upid = request.args.get("upid", "").strip() - node = request.args.get("node", "").strip() - start = request.args.get("start", "0").strip() - try: - start_i = int(start) - except Exception: - start_i = 0 - if not upid or not node: - return jsonify(ok=False, error="upid and node required"), 400 - lines = get_json(["pvesh", "get", f"/nodes/{node}/tasks/{upid}/log", "-start", str(start_i)]) or [] - next_start = start_i - if isinstance(lines, list) and lines: - try: - next_start = max((int(x.get("n", start_i)) for x in lines if isinstance(x, dict)), default=start_i) + 1 - except Exception: - next_start = start_i - return jsonify(ok=True, lines=lines or [], next_start=next_start) - -# ---------------- WebSocket: live tail zadań ---------------- -@sock.route("/ws/task") -def ws_task(ws): - # query: upid, node - q = ws.environ.get("QUERY_STRING", "") - params = {} - for part in q.split("&"): - if not part: continue - k, _, v = part.partition("=") - params[k] = v - upid = params.get("upid", "").strip() - node = params.get("node", "").strip() - if not upid or not node: - ws.send(json.dumps({"type":"error","error":"upid and node are required"})) - return - start = 0 - try: - while True: - st = get_json(["pvesh", "get", f"/nodes/{node}/tasks/{upid}/status"]) or {} - ws.send(json.dumps({"type":"status","status":st})) - lines = get_json(["pvesh", "get", f"/nodes/{node}/tasks/{upid}/log", "-start", str(start)]) or [] - if isinstance(lines, list) and lines: - for ln in lines: - txt = (ln.get("t") if isinstance(ln, dict) else None) - if txt: - ws.send(json.dumps({"type":"log","line":txt})) - try: - start = max((int(x.get("n", start)) for x in lines if isinstance(x, dict)), default=start) + 1 - except Exception: - pass - # koniec - if isinstance(st, dict) and (str(st.get("status","")).lower() == "stopped" or st.get("exitstatus")): - ok = (str(st.get("exitstatus","")).upper() == "OK") - ws.send(json.dumps({"type":"done","ok":ok,"exitstatus":st.get("exitstatus")})) - break - time.sleep(1.2) - except Exception: - try: - ws.close() - except Exception: - pass # ---------------- WebSocket: broadcast observe per sid ---------------- @sock.route("/ws/observe") @@ -596,8 +526,7 @@ def ws_observe(ws): if not sid: ws.send(json.dumps({"type":"error","error":"sid required"})); return - # Resolve tuple + node - def resolve_tuple() -> Optional[Tuple[str,int,str]]: + def resolve_tuple(): meta = cluster_vmct_meta() return sid_to_tuple(sid, meta) @@ -608,7 +537,6 @@ def ws_observe(ws): if not node: ws.send(json.dumps({"type":"error","error":"could not resolve node"})); return - last_hash = None seen_upids = set() prev_node = node @@ -621,13 +549,6 @@ def ws_observe(ws): ws.send(json.dumps({"type":"moved","old_node":node,"new_node":cur_node,"meta":{"sid":sid,"vmid":vmid,"typ":typ}})) prev_node, node = node, cur_node - base = f"/nodes/{node}/{typ}/{vmid}" - cur = get_json(["pvesh", "get", f"{base}/status/current"]) or {} - cur_hash = json.dumps(cur, sort_keys=True) - if cur_hash != last_hash: - last_hash = cur_hash - ws.send(json.dumps({"type":"vm","current":cur,"meta":{"sid":sid,"node":node,"typ":typ,"vmid":vmid}})) - nodes_to_scan = [node] + ([prev_node] if prev_node and prev_node != node else []) for nX in nodes_to_scan: tasks = get_json(["pvesh","get",f"/nodes/{nX}/tasks","-limit","50"]) or [] @@ -635,23 +556,21 @@ def ws_observe(ws): upid = t.get("upid") if isinstance(t, dict) else None tid = (t.get("id") or "") if isinstance(t, dict) else "" if not upid or not isinstance(upid, str): continue - # dopasuj po vmid lub ciągu qemu/|lxc/ if (str(vmid) in tid) or (f"{'qemu' if typ=='qemu' else 'lxc'}/{vmid}" in tid): st = get_json(["pvesh","get",f"/nodes/{nX}/tasks/{upid}/status"]) or {} - ws.send(json.dumps({"type":"task","upid":upid,"status":st.get("status"),"exitstatus":st.get("exitstatus"),"node":nX})) - # nowy running + ws.send(json.dumps({"type":"task","upid":upid,"node":nX})) if upid not in seen_upids and str(st.get("status","")).lower() != "stopped": seen_upids.add(upid) ws.send(json.dumps({"type":"task-start","upid":upid,"node":nX})) - # zakończone if str(st.get("status","")).lower() == "stopped" or st.get("exitstatus"): - ws.send(json.dumps({"type":"done","upid":upid,"ok":str(st.get('exitstatus','')).upper()=='OK',"node":nX})) - + ok = str(st.get("exitstatus","")).upper() == "OK" + ws.send(json.dumps({"type":"done","upid":upid,"ok":ok,"node":nX})) time.sleep(1.8) except Exception: try: ws.close() except Exception: pass + if __name__ == "__main__": import argparse p = argparse.ArgumentParser() diff --git a/static/js/admin.js b/static/js/admin.js index 7090887..cd0adb9 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -1,4 +1,4 @@ -import { badge, rowHTML, setRows, safe, showToast } from './helpers.js'; +import { rowHTML, setRows, safe, showToast } from './helpers.js'; import { api } from './api.js'; const liveSockets = new Map(); @@ -6,11 +6,21 @@ let slowTimer = null; let fastTimer = null; const activeSids = new Set(); +function injectOnceCSS() { + if (document.getElementById('vmadmin-live-css')) return; + const style = document.createElement('style'); + style.id = 'vmadmin-live-css'; + style.textContent = ` + .pulse-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#22c55e;margin-left:.5rem;opacity:.25;animation:pulse-vm 1s ease-in-out 4} + @keyframes pulse-vm {0%,100%{opacity:.25;transform:scale(1)}50%{opacity:1;transform:scale(1.25)}} + `; + document.head.appendChild(style); +} + function closeForSid(sid) { const entry = liveSockets.get(sid); if (!entry) return; try { entry.obs && entry.obs.close(); } catch {} - try { entry.task && entry.task.close(); } catch {} liveSockets.delete(sid); } @@ -21,20 +31,24 @@ export function stopAllAdminWatches() { for (const sid of Array.from(liveSockets.keys())) closeForSid(sid); } -function setBadge(cell, val) { +function flashDot(cell) { if (!cell) return; - cell.innerHTML = val; -} - -function setMigrateDisabled(tr, disabled) { - const btn = tr.querySelector('.act-migrate'); - if (btn) btn.disabled = !!disabled; + const nameWrap = cell.querySelector('.vm-name-wrap') || (() => { + const w = document.createElement('span'); w.className = 'vm-name-wrap'; + while (cell.firstChild) w.appendChild(cell.firstChild); + cell.appendChild(w); + return w; + })(); + const dot = document.createElement('span'); + dot.className = 'pulse-dot'; + nameWrap.appendChild(dot); + setTimeout(() => { try { dot.remove(); } catch {} }, 4200); } function rebuildTargetSelect(selectEl, currentNode, nodes) { if (!selectEl) return; const html = nodes.map(n => - `` + `` ).join(''); selectEl.innerHTML = html; const idx = Array.from(selectEl.options).findIndex(o => !o.disabled); @@ -42,6 +56,7 @@ function rebuildTargetSelect(selectEl, currentNode, nodes) { } export async function renderVMAdmin() { + injectOnceCSS(); stopAllAdminWatches(); const data = await api.listAllVmct(); @@ -52,7 +67,7 @@ export async function renderVMAdmin() { 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('running','ok') : badge(x.status || '—','dark'); + const nameCell = `${name}`; const actions = `
@@ -61,166 +76,75 @@ export async function renderVMAdmin() {
`; const sel = ``; const tools = `
- +
`; - return rowHTML([sid, type.toUpperCase(), name, node, st, actions, sel, tools], `data-sid="${sid}"`); + // Kolumny: SID | TYPE | NAME | NODE | ACTIONS | TARGET | TOOLS + return rowHTML([sid, type.toUpperCase(), nameCell, node, actions, sel, tools], `data-sid="${sid}"`); }); setRows(tbody, rows); Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { const sid = tr.getAttribute('data-sid'); - const colSpan = tr.children.length; const nodeCell = tr.children[3]; - const badgeCell = tr.children[4]; + const nameCell = tr.children[2]; const targetSel = tr.querySelector('.target-node'); - let sub = tr.nextElementSibling; - if (!sub || !sub.classList.contains('mig-row')) { - sub = document.createElement('tr'); sub.className = 'mig-row d-none'; - const td = document.createElement('td'); td.colSpan = colSpan; - td.innerHTML = '
Task status
'; - sub.appendChild(td); tr.parentNode.insertBefore(sub, tr.nextSibling); - } - const logPre = sub.querySelector('.mig-log'); - const toggleSub = (show) => sub.classList.toggle('d-none', !show); - - const setRowBusy = (busy) => { - const nameCell = tr.children[2]; - let spin = nameCell.querySelector('.op-spin'); - if (busy && !spin) { - const span = document.createElement('span'); - span.className = 'op-spin spinner-border spinner-border-sm align-middle ms-2'; - span.setAttribute('role','status'); span.setAttribute('aria-hidden','true'); - nameCell.appendChild(span); - } - if (!busy && spin) spin.remove(); - }; - - const getTarget = () => targetSel?.value || ''; - - const openTaskWS = (upid, node) => { - if (!upid) return; - toggleSub(true); - logPre.textContent = `UPID: ${upid} @ ${node}\n`; - - const entry = liveSockets.get(sid) || {}; - try { entry.task && entry.task.close(); } catch {} - const wsTask = new WebSocket(api.wsTaskURL(upid, node)); - entry.task = wsTask; - liveSockets.set(sid, entry); - activeSids.add(sid); - - wsTask.onmessage = (ev) => { - try { - const msg = JSON.parse(ev.data); - if (msg.type === 'log' && msg.line) { - logPre.textContent += msg.line + '\n'; - logPre.scrollTop = logPre.scrollHeight; - } else if (msg.type === 'status' && msg.status) { - const ok = String(msg.status.exitstatus||'').toUpperCase() === 'OK'; - const s = String(msg.status.status||'').toLowerCase(); - setBadge(badgeCell, ok ? badge('running','ok') : - (s === 'stopped' ? badge('stopped','dark') : badge('working','info'))); - } else if (msg.type === 'done') { - const ok = !!msg.ok; - setBadge(badgeCell, ok ? badge('running','ok') : badge('error','err')); - setRowBusy(false); - setTimeout(() => toggleSub(false), 1000); - setTimeout(() => activeSids.delete(sid), 5000); - } - } catch {} - }; - }; - const ensureWatchOn = () => { const existing = liveSockets.get(sid); if (existing && existing.obs && existing.obs.readyState <= 1) return; closeForSid(sid); - const wsObs = new WebSocket(api.wsObserveURL(sid)); - liveSockets.set(sid, { obs: wsObs, task: null }); + liveSockets.set(sid, { obs: wsObs }); wsObs.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); - if (msg.type === 'vm' && msg.current) { - const st = String(msg.current.status || msg.current.qmpstatus || '').toLowerCase(); - const isRunning = /running|online|started/.test(st); - setBadge(badgeCell, isRunning ? badge('running','ok') : - (/stopp|shutdown|offline/.test(st) ? badge('stopped','dark') : badge(st||'—','info'))); - setMigrateDisabled(tr, isRunning); - if (!isRunning && st) activeSids.add(sid); - } - else if (msg.type === 'task-start' && msg.upid && msg.node) { - openTaskWS(msg.upid, msg.node); + if (msg.type === 'task-start') { activeSids.add(sid); + flashDot(nameCell); } - else if (msg.type === 'task' && msg.upid && msg.status) { - const stopped = String(msg.status||'').toLowerCase() === 'stopped'; - if (stopped && typeof msg.exitstatus !== 'undefined') { - const ok = String(msg.exitstatus||'').toUpperCase() === 'OK'; - setBadge(badgeCell, ok ? badge('running','ok') : badge('error','err')); - setTimeout(() => activeSids.delete(sid), 5000); - } else { - setBadge(badgeCell, badge('working','info')); - activeSids.add(sid); - } + else if (msg.type === 'task') { + activeSids.add(sid); + flashDot(nameCell); } else if (msg.type === 'moved' && msg.new_node) { - if (nodeCell) nodeCell.textContent = msg.new_node; - rebuildTargetSelect(targetSel, msg.new_node, Array.from(new Set([...(window.__nodesCache||[]), msg.new_node]))); + if (nodeCell && nodeCell.textContent.trim() !== msg.new_node) { + nodeCell.textContent = msg.new_node; + rebuildTargetSelect(targetSel, msg.new_node, window.__nodesCache || []); + flashDot(nameCell); + } activeSids.add(sid); } - else if (msg.type === 'done' && typeof msg.ok === 'boolean') { - setBadge(badgeCell, msg.ok ? badge('running','ok') : badge('error','err')); - setTimeout(() => activeSids.delete(sid), 5000); + else if (msg.type === 'done') { + setTimeout(() => activeSids.delete(sid), 4000); + flashDot(nameCell); } } catch {} }; wsObs.onclose = () => { const e = liveSockets.get(sid); - if (e && e.obs === wsObs) { - liveSockets.set(sid, { obs: null, task: e.task || null }); - } + if (e && e.obs === wsObs) liveSockets.set(sid, { obs: null }); }; }; const doAction = async (action, withTarget=false) => { - setRowBusy(true); try { - const target = withTarget ? getTarget() : undefined; - ensureWatchOn(); - if (action !== 'unlock') { - setBadge(badgeCell, badge('working','info')); - toggleSub(true); - activeSids.add(sid); - } - + if (action !== 'unlock') activeSids.add(sid); + const target = withTarget ? (targetSel?.value || '') : undefined; const resp = await api.vmAction(sid, action, target); if (!resp.ok) throw new Error(resp.error || 'unknown'); - - if (!resp.upid) { - logPre.textContent = `Waiting for task… (${action})\n`; - setRowBusy(false); - activeSids.add(sid); - return; - } - - openTaskWS(resp.upid, resp.source_node); - + flashDot(nameCell); } catch (e) { showToast('Error', 'ERROR: ' + (e.message || e), 'danger'); - setRowBusy(false); - toggleSub(false); activeSids.delete(sid); } }; @@ -230,16 +154,17 @@ export async function renderVMAdmin() { tr.querySelector('.act-stop')?.addEventListener('click', () => doAction('stop')); tr.querySelector('.act-shutdown')?.addEventListener('click',() => doAction('shutdown')); tr.querySelector('.act-migrate')?.addEventListener('click', () => doAction('migrate', true)); - tr.querySelector('.act-status')?.addEventListener('click', () => toggleSub(sub.classList.contains('d-none'))); + + tr.querySelector('.act-status')?.addEventListener('click', async () => { + flashDot(nameCell); + }); ensureWatchOn(); - - const initialRunning = /running/i.test(tr.querySelector('td:nth-child(5)')?.innerText || ''); - setMigrateDisabled(tr, initialRunning); }); window.__nodesCache = availableNodes.slice(); + // pełna lista – co 30 s slowTimer = setInterval(async () => { try { const latest = await api.listAllVmct(); @@ -254,22 +179,20 @@ export async function renderVMAdmin() { if (!rowData) return; const nodeCell = tr.children[3]; - const badgeCell = tr.children[4]; + const nameCell = tr.children[2]; const targetSel = tr.querySelector('.target-node'); const newNode = String(rowData.node || '').trim(); if (nodeCell && newNode && nodeCell.textContent.trim() !== newNode) { nodeCell.textContent = newNode; rebuildTargetSelect(targetSel, newNode, nodesNow); + flashDot(nameCell); } - - const isRunning = /running/i.test(rowData.status || ''); - setBadge(badgeCell, isRunning ? badge('running','ok') : badge(rowData.status || '—','dark')); - setMigrateDisabled(tr, isRunning); }); } catch {} }, 30000); + // tylko aktywne – co 10 s fastTimer = setInterval(async () => { try { const sids = Array.from(activeSids); @@ -280,26 +203,18 @@ export async function renderVMAdmin() { const tr = tbody.querySelector(`tr[data-sid="${sid}"]`); if (!tr) continue; const nodeCell = tr.children[3]; - const badgeCell = tr.children[4]; + const nameCell = tr.children[2]; const targetSel = tr.querySelector('.target-node'); - const stRaw = String((detail.current && (detail.current.status || detail.current.qmpstatus)) || '').toLowerCase(); - const isRunning = /running|online|started/.test(stRaw); - setBadge(badgeCell, isRunning ? badge('running','ok') : - (/stopp|shutdown|offline/.test(stRaw) ? badge('stopped','dark') : badge(stRaw||'—','info'))); - setMigrateDisabled(tr, isRunning); - const newNode = String(detail.node || (detail.meta && detail.meta.node) || '').trim(); if (newNode) { if (nodeCell && nodeCell.textContent.trim() !== newNode) { nodeCell.textContent = newNode; + flashDot(nameCell); } rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); } - - if (stRaw && /running|stopped|shutdown/.test(stRaw)) { - setTimeout(() => activeSids.delete(sid), 5000); - } + setTimeout(() => activeSids.delete(sid), 4000); } } catch {} }, 10000); diff --git a/templates/index.html b/templates/index.html index 9bb025c..db4f3dd 100644 --- a/templates/index.html +++ b/templates/index.html @@ -148,7 +148,7 @@ -
Kliknij wiersz, aby rozwinąć szczegóły VM/CT.
+
Click to expand VM/CT data.
@@ -255,7 +255,7 @@ -
Kliknij wiersz, aby rozwinąć szczegóły noda.
+
Click to expand node data.
From 8e3d191233fa6424b1b6fac32d74007cd3ae7ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 22:34:51 +0200 Subject: [PATCH 11/28] refator_comm1 --- static/js/admin.js | 18 ++++++++---------- templates/index.html | 6 +++--- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index cd0adb9..df1197d 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -55,6 +55,11 @@ function rebuildTargetSelect(selectEl, currentNode, nodes) { if (idx >= 0) selectEl.selectedIndex = idx; } +function setMigrateDisabled(tr, disabled) { + const btn = tr.querySelector('.act-migrate'); + if (btn) btn.disabled = !!disabled; +} + export async function renderVMAdmin() { injectOnceCSS(); stopAllAdminWatches(); @@ -78,12 +83,9 @@ export async function renderVMAdmin() { const sel = ``; - const tools = `
- - -
`; - // Kolumny: SID | TYPE | NAME | NODE | ACTIONS | TARGET | TOOLS - return rowHTML([sid, type.toUpperCase(), nameCell, node, actions, sel, tools], `data-sid="${sid}"`); + const migrateBtn = ``; + // Kolumny: SID | TYPE | NAME | NODE | ACTIONS | TARGET | MIGRATE + return rowHTML([sid, type.toUpperCase(), nameCell, node, actions, sel, migrateBtn], `data-sid="${sid}"`); }); setRows(tbody, rows); @@ -155,10 +157,6 @@ export async function renderVMAdmin() { tr.querySelector('.act-shutdown')?.addEventListener('click',() => doAction('shutdown')); tr.querySelector('.act-migrate')?.addEventListener('click', () => doAction('migrate', true)); - tr.querySelector('.act-status')?.addEventListener('click', async () => { - flashDot(nameCell); - }); - ensureWatchOn(); }); diff --git a/templates/index.html b/templates/index.html index db4f3dd..5aaa883 100644 --- a/templates/index.html +++ b/templates/index.html @@ -215,7 +215,6 @@ Type Name Node - Status Actions Target Migrate @@ -223,15 +222,16 @@ - Loading… + Loading… -
Akcje: Unlock (qm), Start/Stop/Shutdown, Offline migrate.
+
Actions: Unlock (qm), Start/Stop/Shutdown, Offline migrate.
+
From a841a1f9adcab5e672f62d05708abca67f180712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 22:45:56 +0200 Subject: [PATCH 12/28] refator_comm1 --- app.py | 13 +++++++++ static/js/admin.js | 65 ++++++++++++++++++++++++++++++++++++++------ templates/index.html | 3 +- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/app.py b/app.py index 7714d84..31b77cb 100644 --- a/app.py +++ b/app.py @@ -537,6 +537,7 @@ def ws_observe(ws): if not node: ws.send(json.dumps({"type":"error","error":"could not resolve node"})); return + last_hash = None seen_upids = set() prev_node = node @@ -549,6 +550,18 @@ def ws_observe(ws): ws.send(json.dumps({"type":"moved","old_node":node,"new_node":cur_node,"meta":{"sid":sid,"vmid":vmid,"typ":typ}})) prev_node, node = node, cur_node + # bieżący status VM/CT -> event "vm" + try: + base = f"/nodes/{node}/{typ}/{vmid}" + cur = get_json(["pvesh", "get", f"{base}/status/current"]) or {} + cur_hash = json.dumps(cur, sort_keys=True) + if cur_hash != last_hash: + last_hash = cur_hash + ws.send(json.dumps({"type":"vm","current":cur,"meta":{"sid":sid,"node":node,"typ":typ,"vmid":vmid}})) + except Exception: + pass + + # zadania na aktualnym i poprzednim nodzie nodes_to_scan = [node] + ([prev_node] if prev_node and prev_node != node else []) for nX in nodes_to_scan: tasks = get_json(["pvesh","get",f"/nodes/{nX}/tasks","-limit","50"]) or [] diff --git a/static/js/admin.js b/static/js/admin.js index df1197d..4c88b9c 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -1,4 +1,4 @@ -import { rowHTML, setRows, safe, showToast } from './helpers.js'; +import { rowHTML, setRows, safe, showToast, badge } from './helpers.js'; import { api } from './api.js'; const liveSockets = new Map(); @@ -45,6 +45,21 @@ function flashDot(cell) { setTimeout(() => { try { dot.remove(); } catch {} }, 4200); } +function setBadgeCell(cell, textOrState) { + if (!cell) return; + let html = ''; + const s = String(textOrState || '').toLowerCase(); + if (/running|online|started/.test(s)) html = badge('running','ok'); + else if (/stopp|shutdown|offline/.test(s)) html = badge('stopped','dark'); + else if (/working|progress|busy/.test(s)) html = badge('working','info'); + else html = badge(textOrState || '—','dark'); + if (cell.innerHTML !== html) { + cell.innerHTML = html; + return true; // zmiana + } + return false; +} + function rebuildTargetSelect(selectEl, currentNode, nodes) { if (!selectEl) return; const html = nodes.map(n => @@ -73,6 +88,7 @@ export async function renderVMAdmin() { const rows = arr.map(x => { const sid = safe(x.sid), type = safe(x.type), name = safe(x.name), node = safe(x.node); const nameCell = `${name}`; + const statusCell = badge('—','dark'); const actions = `
@@ -84,8 +100,8 @@ export async function renderVMAdmin() { ${availableNodes.map(n => ``).join('')} `; const migrateBtn = ``; - // Kolumny: SID | TYPE | NAME | NODE | ACTIONS | TARGET | MIGRATE - return rowHTML([sid, type.toUpperCase(), nameCell, node, actions, sel, migrateBtn], `data-sid="${sid}"`); + // SID | TYPE | NAME | NODE | STATUS | ACTIONS | TARGET | MIGRATE + return rowHTML([sid, type.toUpperCase(), nameCell, node, statusCell, actions, sel, migrateBtn], `data-sid="${sid}"`); }); setRows(tbody, rows); @@ -93,6 +109,7 @@ export async function renderVMAdmin() { Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { const sid = tr.getAttribute('data-sid'); const nodeCell = tr.children[3]; + const statusCell= tr.children[4]; const nameCell = tr.children[2]; const targetSel = tr.querySelector('.target-node'); @@ -108,13 +125,21 @@ export async function renderVMAdmin() { try { const msg = JSON.parse(ev.data); - if (msg.type === 'task-start') { + if (msg.type === 'vm' && msg.current) { + const st = String(msg.current.status || msg.current.qmpstatus || '').toLowerCase(); + const changed = setBadgeCell(statusCell, st); + const isRunning = /running|online|started/.test(st); + setMigrateDisabled(tr, isRunning); + if (changed) flashDot(nameCell); + } + else if (msg.type === 'task-start') { + setBadgeCell(statusCell, 'working'); activeSids.add(sid); flashDot(nameCell); } else if (msg.type === 'task') { + setBadgeCell(statusCell, 'working'); activeSids.add(sid); - flashDot(nameCell); } else if (msg.type === 'moved' && msg.new_node) { if (nodeCell && nodeCell.textContent.trim() !== msg.new_node) { @@ -125,6 +150,7 @@ export async function renderVMAdmin() { activeSids.add(sid); } else if (msg.type === 'done') { + // status końcowy dociągnie kolejny pakiet "vm" lub szybki refresh setTimeout(() => activeSids.delete(sid), 4000); flashDot(nameCell); } @@ -140,7 +166,10 @@ export async function renderVMAdmin() { const doAction = async (action, withTarget=false) => { try { ensureWatchOn(); - if (action !== 'unlock') activeSids.add(sid); + if (action !== 'unlock') { + setBadgeCell(statusCell, 'working'); + activeSids.add(sid); + } const target = withTarget ? (targetSel?.value || '') : undefined; const resp = await api.vmAction(sid, action, target); if (!resp.ok) throw new Error(resp.error || 'unknown'); @@ -177,6 +206,7 @@ export async function renderVMAdmin() { if (!rowData) return; const nodeCell = tr.children[3]; + const statusCell= tr.children[4]; const nameCell = tr.children[2]; const targetSel = tr.querySelector('.target-node'); @@ -186,11 +216,17 @@ export async function renderVMAdmin() { rebuildTargetSelect(targetSel, newNode, nodesNow); flashDot(nameCell); } + + // status z wolnego reconcile — tylko gdy brak „working”, żeby nie zagłuszać WS + const currentTxt = (statusCell?.innerText || '').toLowerCase(); + if (!/working/.test(currentTxt)) { + // brak statusu w liście — zostaw jak jest, dociągnie WS/fastTimer + } }); } catch {} }, 30000); - // tylko aktywne – co 10 s + // tylko aktywne – co 10 s (dociąga precyzyjny status + node) fastTimer = setInterval(async () => { try { const sids = Array.from(activeSids); @@ -200,19 +236,30 @@ export async function renderVMAdmin() { if (!detail || !detail.meta) continue; const tr = tbody.querySelector(`tr[data-sid="${sid}"]`); if (!tr) continue; + const nodeCell = tr.children[3]; + const statusCell= tr.children[4]; const nameCell = tr.children[2]; const targetSel = tr.querySelector('.target-node'); + const stRaw = String((detail.current && (detail.current.status || detail.current.qmpstatus)) || '').toLowerCase(); + const changed = setBadgeCell(statusCell, stRaw); + const isRunning = /running|online|started/.test(stRaw); + setMigrateDisabled(tr, isRunning); + if (changed) flashDot(nameCell); + const newNode = String(detail.node || (detail.meta && detail.meta.node) || '').trim(); if (newNode) { if (nodeCell && nodeCell.textContent.trim() !== newNode) { nodeCell.textContent = newNode; + rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); flashDot(nameCell); } - rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); } - setTimeout(() => activeSids.delete(sid), 4000); + + if (stRaw && /running|stopped|shutdown/.test(stRaw)) { + setTimeout(() => activeSids.delete(sid), 4000); + } } } catch {} }, 10000); diff --git a/templates/index.html b/templates/index.html index 5aaa883..8dd06b4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -215,6 +215,7 @@ Type Name Node + Status Actions Target Migrate @@ -222,7 +223,7 @@ - Loading… + Loading… From 2ffbe9badf41b35519cbd643abe00038fb2993b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 22:57:05 +0200 Subject: [PATCH 13/28] refator_comm1 --- static/js/admin.js | 398 +++++++++++++++++++++++---------------------- static/styles.css | 12 ++ 2 files changed, 218 insertions(+), 192 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index 4c88b9c..cea6d22 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -17,32 +17,12 @@ function injectOnceCSS() { document.head.appendChild(style); } -function closeForSid(sid) { - const entry = liveSockets.get(sid); - if (!entry) return; - try { entry.obs && entry.obs.close(); } catch {} - liveSockets.delete(sid); -} - -export function stopAllAdminWatches() { - if (slowTimer) { clearInterval(slowTimer); slowTimer = null; } - if (fastTimer) { clearInterval(fastTimer); fastTimer = null; } - activeSids.clear(); - for (const sid of Array.from(liveSockets.keys())) closeForSid(sid); -} - function flashDot(cell) { if (!cell) return; - const nameWrap = cell.querySelector('.vm-name-wrap') || (() => { - const w = document.createElement('span'); w.className = 'vm-name-wrap'; - while (cell.firstChild) w.appendChild(cell.firstChild); - cell.appendChild(w); - return w; - })(); const dot = document.createElement('span'); dot.className = 'pulse-dot'; - nameWrap.appendChild(dot); - setTimeout(() => { try { dot.remove(); } catch {} }, 4200); + cell.appendChild(dot); + setTimeout(() => dot.remove(), 1500); } function setBadgeCell(cell, textOrState) { @@ -66,203 +46,237 @@ function rebuildTargetSelect(selectEl, currentNode, nodes) { `` ).join(''); selectEl.innerHTML = html; - const idx = Array.from(selectEl.options).findIndex(o => !o.disabled); - if (idx >= 0) selectEl.selectedIndex = idx; + const idx = Array.from(selectEl.options).findIndex(o => o.disabled); + selectEl.selectedIndex = idx >= 0 ? idx : 0; } -function setMigrateDisabled(tr, disabled) { - const btn = tr.querySelector('.act-migrate'); - if (btn) btn.disabled = !!disabled; +function setMigrateDisabled(tr, isRunning) { + const btn = tr?.querySelector('.act-migrate'); + if (!btn) return; + if (isRunning) { + btn.removeAttribute('disabled'); + btn.classList.remove('disabled'); + } else { + btn.setAttribute('disabled', ''); + btn.classList.add('disabled'); + } } -export async function renderVMAdmin() { - injectOnceCSS(); - stopAllAdminWatches(); +export function stopAllAdminWatches() { + liveSockets.forEach(ws => { try { ws.close(); } catch {} }); + liveSockets.clear(); + if (slowTimer) clearInterval(slowTimer); + if (fastTimer) clearInterval(fastTimer); + slowTimer = null; + fastTimer = null; + activeSids.clear(); +} - const data = await api.listAllVmct(); - const arr = Array.isArray(data.all) ? data.all : []; - const availableNodes = Array.isArray(data.nodes) ? data.nodes : []; +function ensureWatchOn() { const tbody = document.querySelector('#vm-admin tbody'); - if (!arr.length) { setRows(tbody, [rowHTML(['Brak VM/CT'])]); return; } - - const rows = arr.map(x => { - const sid = safe(x.sid), type = safe(x.type), name = safe(x.name), node = safe(x.node); - const nameCell = `${name}`; - const statusCell = badge('—','dark'); - const actions = ` -
- - - - -
`; - const sel = ``; - const migrateBtn = ``; - // SID | TYPE | NAME | NODE | STATUS | ACTIONS | TARGET | MIGRATE - return rowHTML([sid, type.toUpperCase(), nameCell, node, statusCell, actions, sel, migrateBtn], `data-sid="${sid}"`); - }); - - setRows(tbody, rows); - + if (!tbody) return; Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { const sid = tr.getAttribute('data-sid'); - const nodeCell = tr.children[3]; - const statusCell= tr.children[4]; - const nameCell = tr.children[2]; - const targetSel = tr.querySelector('.target-node'); + if (!sid) return; + if (liveSockets.has(sid)) return; - const ensureWatchOn = () => { - const existing = liveSockets.get(sid); - if (existing && existing.obs && existing.obs.readyState <= 1) return; - - closeForSid(sid); - const wsObs = new WebSocket(api.wsObserveURL(sid)); - liveSockets.set(sid, { obs: wsObs }); - - wsObs.onmessage = (ev) => { + try { + const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/observe?sid=${encodeURIComponent(sid)}`); + liveSockets.set(sid, ws); + ws.onopen = () => {}; + ws.onclose = () => { liveSockets.delete(sid); }; + ws.onerror = () => {}; + ws.onmessage = (ev) => { try { - const msg = JSON.parse(ev.data); + const msg = JSON.parse(ev.data || '{}'); + if (!msg || !msg.type) return; + const tr = tbody.querySelector(`tr[data-sid="${sid}"]`); + if (!tr) return; + const statusCell = tr.children[4]; + const nameCell = tr.children[2]; + const nodeCell = tr.children[3]; + const targetSel = tr.querySelector('.target-node'); - if (msg.type === 'vm' && msg.current) { - const st = String(msg.current.status || msg.current.qmpstatus || '').toLowerCase(); - const changed = setBadgeCell(statusCell, st); - const isRunning = /running|online|started/.test(st); + if (msg.type === 'status') { + const stRaw = String(msg.status || '').toLowerCase(); + const changed = setBadgeCell(statusCell, stRaw); + const isRunning = /running|online|started/.test(stRaw); setMigrateDisabled(tr, isRunning); if (changed) flashDot(nameCell); + if (stRaw && /running|stopped|shutdown/.test(stRaw)) { + setTimeout(() => activeSids.delete(sid), 3000); + } } - else if (msg.type === 'task-start') { - setBadgeCell(statusCell, 'working'); - activeSids.add(sid); - flashDot(nameCell); - } - else if (msg.type === 'task') { - setBadgeCell(statusCell, 'working'); - activeSids.add(sid); - } - else if (msg.type === 'moved' && msg.new_node) { - if (nodeCell && nodeCell.textContent.trim() !== msg.new_node) { - nodeCell.textContent = msg.new_node; - rebuildTargetSelect(targetSel, msg.new_node, window.__nodesCache || []); + + if (msg.type === 'node' && msg.node) { + const newNode = String(msg.node).trim(); + if (nodeCell && newNode && nodeCell.textContent.trim() !== newNode) { + nodeCell.textContent = newNode; + rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); flashDot(nameCell); } - activeSids.add(sid); - } - else if (msg.type === 'done') { - // status końcowy dociągnie kolejny pakiet "vm" lub szybki refresh - setTimeout(() => activeSids.delete(sid), 4000); - flashDot(nameCell); } } catch {} }; - - wsObs.onclose = () => { - const e = liveSockets.get(sid); - if (e && e.obs === wsObs) liveSockets.set(sid, { obs: null }); - }; - }; - - const doAction = async (action, withTarget=false) => { - try { - ensureWatchOn(); - if (action !== 'unlock') { - setBadgeCell(statusCell, 'working'); - activeSids.add(sid); - } - const target = withTarget ? (targetSel?.value || '') : undefined; - const resp = await api.vmAction(sid, action, target); - if (!resp.ok) throw new Error(resp.error || 'unknown'); - flashDot(nameCell); - } catch (e) { - showToast('Error', 'ERROR: ' + (e.message || e), 'danger'); - activeSids.delete(sid); - } - }; - - tr.querySelector('.act-unlock')?.addEventListener('click', () => doAction('unlock')); - tr.querySelector('.act-start')?.addEventListener('click', () => doAction('start')); - tr.querySelector('.act-stop')?.addEventListener('click', () => doAction('stop')); - tr.querySelector('.act-shutdown')?.addEventListener('click',() => doAction('shutdown')); - tr.querySelector('.act-migrate')?.addEventListener('click', () => doAction('migrate', true)); - - ensureWatchOn(); - }); - - window.__nodesCache = availableNodes.slice(); - - // pełna lista – co 30 s - slowTimer = setInterval(async () => { - try { - const latest = await api.listAllVmct(); - const all = Array.isArray(latest.all) ? latest.all : []; - const bySid = new Map(all.map(x => [String(x.sid), x])); - const nodesNow = Array.isArray(latest.nodes) ? latest.nodes : window.__nodesCache || []; - window.__nodesCache = nodesNow; - - Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { - const sid = tr.getAttribute('data-sid'); - const rowData = bySid.get(sid); - if (!rowData) return; - - const nodeCell = tr.children[3]; - const statusCell= tr.children[4]; - const nameCell = tr.children[2]; - const targetSel = tr.querySelector('.target-node'); - - const newNode = String(rowData.node || '').trim(); - if (nodeCell && newNode && nodeCell.textContent.trim() !== newNode) { - nodeCell.textContent = newNode; - rebuildTargetSelect(targetSel, newNode, nodesNow); - flashDot(nameCell); - } - - // status z wolnego reconcile — tylko gdy brak „working”, żeby nie zagłuszać WS - const currentTxt = (statusCell?.innerText || '').toLowerCase(); - if (!/working/.test(currentTxt)) { - // brak statusu w liście — zostaw jak jest, dociągnie WS/fastTimer - } - }); } catch {} - }, 30000); + }); +} - // tylko aktywne – co 10 s (dociąga precyzyjny status + node) - fastTimer = setInterval(async () => { - try { - const sids = Array.from(activeSids); - if (!sids.length) return; - for (const sid of sids) { - const detail = await api.vmDetail(sid); - if (!detail || !detail.meta) continue; - const tr = tbody.querySelector(`tr[data-sid="${sid}"]`); - if (!tr) continue; +export async function startAdminWatches() { + injectOnceCSS(); - const nodeCell = tr.children[3]; - const statusCell= tr.children[4]; - const nameCell = tr.children[2]; - const targetSel = tr.querySelector('.target-node'); + const tbody = document.querySelector('#vm-admin tbody'); + if (!tbody) return; - const stRaw = String((detail.current && (detail.current.status || detail.current.qmpstatus)) || '').toLowerCase(); - const changed = setBadgeCell(statusCell, stRaw); - const isRunning = /running|online|started/.test(stRaw); - setMigrateDisabled(tr, isRunning); - if (changed) flashDot(nameCell); + const availableNodes = await api.listNodes(); + setRows(tbody, []); - const newNode = String(detail.node || (detail.meta && detail.meta.node) || '').trim(); - if (newNode) { - if (nodeCell && nodeCell.textContent.trim() !== newNode) { + // inicjalne wypełnienie tabeli + try { + const latest = await api.listAllVmct(); + const all = Array.isArray(latest.all) ? latest.all : []; + const nodesNow = Array.isArray(latest.nodes) ? latest.nodes : []; + window.__nodesCache = nodesNow.slice(); + + const rows = all.map(vm => ({ + sid: safe(vm.sid), + name: safe(vm.name || vm.vmid || vm.sid), + node: safe(vm.node || '—'), + status: safe(vm.status || '—') + })); + + const html = rows.map(r => rowHTML([ + ``, + safe(r.sid), + safe(r.name), + safe(r.node), + badge(safe(r.status), /running|online|started/i.test(r.status) ? 'ok' : 'dark'), + ``, + `
+ + + +
` + ])).join(''); + + setRows(tbody, html); + + // podłącz selecty z node'ami i akcje + Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { + const nodeCell = tr.children[3]; + const targetSel = tr.querySelector('.target-node'); + rebuildTargetSelect(targetSel, nodeCell?.textContent.trim(), availableNodes); + + const sid = tr.getAttribute('data-sid'); + const nameCell = tr.children[2]; + + async function doAction(kind, needsTarget) { + try { + const targetNode = needsTarget ? targetSel?.value : undefined; + activeSids.add(sid); + setBadgeCell(tr.children[4], 'working'); + setMigrateDisabled(tr, false); + const res = await api.vmAction(sid, kind, targetNode); + if (res?.ok) { + showToast(`Zadanie ${kind} wystartowało dla ${safe(nameCell.textContent)}`); + } else { + showToast(`Błąd zadania ${kind} dla ${safe(nameCell.textContent)}`, 'danger'); + } + } catch (e) { + showToast(`Błąd: ${e?.message || e}`, 'danger'); + } + } + + tr.querySelector('.act-stop')?.addEventListener('click', () => doAction('stop')); + tr.querySelector('.act-shutdown')?.addEventListener('click', () => doAction('shutdown')); + tr.querySelector('.act-migrate')?.addEventListener('click', () => doAction('migrate', true)); + + ensureWatchOn(); + }); + + window.__nodesCache = availableNodes.slice(); + + // pełna lista – co 30 s + slowTimer = setInterval(async () => { + try { + const latest = await api.listAllVmct(); + const all = Array.isArray(latest.all) ? latest.all : []; + const bySid = new Map(all.map(x => [String(x.sid), x])); + const nodesNow = Array.isArray(latest.nodes) ? latest.nodes : window.__nodesCache || []; + window.__nodesCache = nodesNow; + + Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { + const sid = tr.getAttribute('data-sid'); + const rowData = bySid.get(sid); + if (!rowData) return; + + const nodeCell = tr.children[3]; + const statusCell= tr.children[4]; + const nameCell = tr.children[2]; + const targetSel = tr.querySelector('.target-node'); + + const newNode = String(rowData.node || '').trim(); + if (nodeCell && newNode && nodeCell.textContent.trim() !== newNode) { nodeCell.textContent = newNode; - rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); + rebuildTargetSelect(targetSel, newNode, nodesNow); flashDot(nameCell); } - } - if (stRaw && /running|stopped|shutdown/.test(stRaw)) { - setTimeout(() => activeSids.delete(sid), 4000); - } - } - } catch {} - }, 10000); + // status z wolnego reconcile — tylko gdy brak „working”, żeby nie zagłuszać WS + const currentTxt = (statusCell?.innerText || '').toLowerCase(); + if (!/working/.test(currentTxt)) { + const stRaw = String(rowData.status || '').toLowerCase(); // fallback z /cluster/resources + if (stRaw) { + const changed = setBadgeCell(statusCell, stRaw); + const isRunning = /running|online|started/.test(stRaw); + setMigrateDisabled(tr, isRunning); + if (changed) flashDot(nameCell); + } + } + }); + } catch {} + }, 30000); - window.addEventListener('beforeunload', stopAllAdminWatches, { once: true }); + // tylko aktywne – co 10 s (dociąga precyzyjny status + node) + fastTimer = setInterval(async () => { + try { + const sids = Array.from(activeSids); + if (!sids.length) return; + for (const sid of sids) { + const detail = await api.vmDetail(sid); + if (!detail || !detail.meta) continue; + const tr = tbody.querySelector(`tr[data-sid="${sid}"]`); + if (!tr) continue; + + const nodeCell = tr.children[3]; + const statusCell= tr.children[4]; + const nameCell = tr.children[2]; + const targetSel = tr.querySelector('.target-node'); + + const stRaw = String((detail.current && (detail.current.status || detail.current.qmpstatus)) || '').toLowerCase(); + const changed = setBadgeCell(statusCell, stRaw); + const isRunning = /running|online|started/.test(stRaw); + setMigrateDisabled(tr, isRunning); + if (changed) flashDot(nameCell); + + const newNode = String(detail.node || (detail.meta && detail.meta.node) || '').trim(); + if (newNode) { + if (nodeCell && nodeCell.textContent.trim() !== newNode) { + nodeCell.textContent = newNode; + rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); + flashDot(nameCell); + } + } + + if (stRaw && /running|stopped|shutdown/.test(stRaw)) { + setTimeout(() => activeSids.delete(sid), 4000); + } + } + } catch {} + }, 10000); + + window.addEventListener('beforeunload', stopAllAdminWatches, { once: true }); + } catch (e) { + showToast(`Nie udało się załadować listy: ${e?.message || e}`, 'danger'); + } } diff --git a/static/styles.css b/static/styles.css index 4f00865..48b454c 100644 --- a/static/styles.css +++ b/static/styles.css @@ -142,4 +142,16 @@ footer.site-footer a:hover { 100% { background-position: 0 0; } +} + +#toast-container { + position: fixed; + right: max(env(safe-area-inset-right), 1rem); + bottom: max(env(safe-area-inset-bottom), 1rem); + z-index: 1080; + pointer-events: none; +} + +#toast-container .toast { + pointer-events: auto; } \ No newline at end of file From e1031d8ae0c2e708ef2959803ba8444e67064e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 23:00:43 +0200 Subject: [PATCH 14/28] refator_comm1 --- static/js/admin.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/static/js/admin.js b/static/js/admin.js index cea6d22..3131b5c 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -280,3 +280,13 @@ export async function startAdminWatches() { showToast(`Nie udało się załadować listy: ${e?.message || e}`, 'danger'); } } + +// Entry point expected by main.js +export async function renderVMAdmin() { + try { + await startAdminWatches(); + } catch (e) { + showToast(`Błąd inicjalizacji VM Admin: ${e?.message || e}`, 'danger'); + console.error(e); + } +} From 3dd0131088bc0fc4858b3b6fe6638d2aa9ba78ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 23:03:16 +0200 Subject: [PATCH 15/28] refator_comm1 --- static/js/admin.js | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index 3131b5c..ed56432 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -35,7 +35,7 @@ function setBadgeCell(cell, textOrState) { else html = badge(textOrState || '—','dark'); if (cell.innerHTML !== html) { cell.innerHTML = html; - return true; // zmiana + return true; // changed } return false; } @@ -128,10 +128,13 @@ export async function startAdminWatches() { const tbody = document.querySelector('#vm-admin tbody'); if (!tbody) return; - const availableNodes = await api.listNodes(); + const ns = await api.nodesSummary(); + const availableNodes = Array.isArray(ns?.nodes) + ? Array.from(new Set(ns.nodes.map(n => String(n.name || n.node || n).trim()).filter(Boolean))) + : (Array.isArray(ns) ? Array.from(new Set(ns.map(n => String(n.name || n.node || n).trim()).filter(Boolean))) : []); setRows(tbody, []); - // inicjalne wypełnienie tabeli + // initial table fill try { const latest = await api.listAllVmct(); const all = Array.isArray(latest.all) ? latest.all : []; @@ -161,7 +164,7 @@ export async function startAdminWatches() { setRows(tbody, html); - // podłącz selecty z node'ami i akcje + // wire node selects and actions Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { const nodeCell = tr.children[3]; const targetSel = tr.querySelector('.target-node'); @@ -178,12 +181,12 @@ export async function startAdminWatches() { setMigrateDisabled(tr, false); const res = await api.vmAction(sid, kind, targetNode); if (res?.ok) { - showToast(`Zadanie ${kind} wystartowało dla ${safe(nameCell.textContent)}`); + showToast(`Task ${kind} started for ${safe(nameCell.textContent)}`); } else { - showToast(`Błąd zadania ${kind} dla ${safe(nameCell.textContent)}`, 'danger'); + showToast(`Task ${kind} failed for ${safe(nameCell.textContent)}`, 'danger'); } } catch (e) { - showToast(`Błąd: ${e?.message || e}`, 'danger'); + showToast(`Error: ${e?.message || e}`, 'danger'); } } @@ -196,7 +199,7 @@ export async function startAdminWatches() { window.__nodesCache = availableNodes.slice(); - // pełna lista – co 30 s + // full refresh – every 30s slowTimer = setInterval(async () => { try { const latest = await api.listAllVmct(); @@ -222,7 +225,7 @@ export async function startAdminWatches() { flashDot(nameCell); } - // status z wolnego reconcile — tylko gdy brak „working”, żeby nie zagłuszać WS + // status from slow reconcile — only when not 'working' to avoid overruling WS const currentTxt = (statusCell?.innerText || '').toLowerCase(); if (!/working/.test(currentTxt)) { const stRaw = String(rowData.status || '').toLowerCase(); // fallback z /cluster/resources @@ -237,7 +240,7 @@ export async function startAdminWatches() { } catch {} }, 30000); - // tylko aktywne – co 10 s (dociąga precyzyjny status + node) + // active only – every 10s (pull precise status + node) fastTimer = setInterval(async () => { try { const sids = Array.from(activeSids); @@ -277,7 +280,7 @@ export async function startAdminWatches() { window.addEventListener('beforeunload', stopAllAdminWatches, { once: true }); } catch (e) { - showToast(`Nie udało się załadować listy: ${e?.message || e}`, 'danger'); + showToast(`Failed to load list: ${e?.message || e}`, 'danger'); } } @@ -286,7 +289,7 @@ export async function renderVMAdmin() { try { await startAdminWatches(); } catch (e) { - showToast(`Błąd inicjalizacji VM Admin: ${e?.message || e}`, 'danger'); + showToast(`VM Admin initialization error: ${e?.message || e}`, 'danger'); console.error(e); } } From e73298d9bc2c2a1e46a78a2cc88e9c0484d60f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 23:05:03 +0200 Subject: [PATCH 16/28] refator_comm1 --- static/js/admin.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index ed56432..25660e3 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -148,7 +148,7 @@ export async function startAdminWatches() { status: safe(vm.status || '—') })); - const html = rows.map(r => rowHTML([ + const htmlRows = rows.map(r => rowHTML([ ``, safe(r.sid), safe(r.name), @@ -160,9 +160,9 @@ export async function startAdminWatches() {
` - ])).join(''); + ])); - setRows(tbody, html); + setRows(tbody, htmlRows); // wire node selects and actions Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { From cdc13def491269733ac50260958437355b386be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 23:11:25 +0200 Subject: [PATCH 17/28] refator_comm1 --- static/js/admin.js | 142 +++++++++++++++++---------------------------- 1 file changed, 54 insertions(+), 88 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index 25660e3..7c9f1c4 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -41,19 +41,25 @@ function setBadgeCell(cell, textOrState) { } function rebuildTargetSelect(selectEl, currentNode, nodes) { - if (!selectEl) return; - const html = nodes.map(n => - `` - ).join(''); - selectEl.innerHTML = html; - const idx = Array.from(selectEl.options).findIndex(o => o.disabled); - selectEl.selectedIndex = idx >= 0 ? idx : 0; + if (!selectEl) return []; + const others = (nodes || []) + .map(n => String(n).trim()) + .filter(Boolean) + .filter(n => n !== String(currentNode || '').trim()); + selectEl.innerHTML = others.map(n => ``).join(''); + if (selectEl.options.length > 0) { + selectEl.selectedIndex = 0; + } + return others; // return list to decide enable/disable of MIGRATE } -function setMigrateDisabled(tr, isRunning) { +function updateMigrateButton(tr, isRunning) { const btn = tr?.querySelector('.act-migrate'); + const targetSel = tr?.querySelector('.target-node'); if (!btn) return; - if (isRunning) { + const hasTarget = targetSel && targetSel.options && targetSel.options.length > 0; + const enable = isRunning && hasTarget; + if (enable) { btn.removeAttribute('disabled'); btn.classList.remove('disabled'); } else { @@ -62,74 +68,24 @@ function setMigrateDisabled(tr, isRunning) { } } -export function stopAllAdminWatches() { - liveSockets.forEach(ws => { try { ws.close(); } catch {} }); - liveSockets.clear(); - if (slowTimer) clearInterval(slowTimer); - if (fastTimer) clearInterval(fastTimer); - slowTimer = null; - fastTimer = null; - activeSids.clear(); +function updateActionButtons(tr, isRunning) { + const bStart = tr?.querySelector('.act-start'); + const bStop = tr?.querySelector('.act-stop'); + const bShutdown = tr?.querySelector('.act-shutdown'); + if (bStart) { + if (isRunning) { bStart.setAttribute('disabled',''); bStart.classList.add('disabled'); } + else { bStart.removeAttribute('disabled'); bStart.classList.remove('disabled'); } + } + if (bStop) { + if (isRunning) { bStop.removeAttribute('disabled'); bStop.classList.remove('disabled'); } + else { bStop.setAttribute('disabled',''); bStop.classList.add('disabled'); } + } + if (bShutdown) { + if (isRunning) { bShutdown.removeAttribute('disabled'); bShutdown.classList.remove('disabled'); } + else { bShutdown.setAttribute('disabled',''); bShutdown.classList.add('disabled'); } + } } -function ensureWatchOn() { - const tbody = document.querySelector('#vm-admin tbody'); - if (!tbody) return; - Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { - const sid = tr.getAttribute('data-sid'); - if (!sid) return; - if (liveSockets.has(sid)) return; - - try { - const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/observe?sid=${encodeURIComponent(sid)}`); - liveSockets.set(sid, ws); - ws.onopen = () => {}; - ws.onclose = () => { liveSockets.delete(sid); }; - ws.onerror = () => {}; - ws.onmessage = (ev) => { - try { - const msg = JSON.parse(ev.data || '{}'); - if (!msg || !msg.type) return; - const tr = tbody.querySelector(`tr[data-sid="${sid}"]`); - if (!tr) return; - const statusCell = tr.children[4]; - const nameCell = tr.children[2]; - const nodeCell = tr.children[3]; - const targetSel = tr.querySelector('.target-node'); - - if (msg.type === 'status') { - const stRaw = String(msg.status || '').toLowerCase(); - const changed = setBadgeCell(statusCell, stRaw); - const isRunning = /running|online|started/.test(stRaw); - setMigrateDisabled(tr, isRunning); - if (changed) flashDot(nameCell); - if (stRaw && /running|stopped|shutdown/.test(stRaw)) { - setTimeout(() => activeSids.delete(sid), 3000); - } - } - - if (msg.type === 'node' && msg.node) { - const newNode = String(msg.node).trim(); - if (nodeCell && newNode && nodeCell.textContent.trim() !== newNode) { - nodeCell.textContent = newNode; - rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); - flashDot(nameCell); - } - } - } catch {} - }; - } catch {} - }); -} - -export async function startAdminWatches() { - injectOnceCSS(); - - const tbody = document.querySelector('#vm-admin tbody'); - if (!tbody) return; - - const ns = await api.nodesSummary(); - const availableNodes = Array.isArray(ns?.nodes) ? Array.from(new Set(ns.nodes.map(n => String(n.name || n.node || n).trim()).filter(Boolean))) : (Array.isArray(ns) ? Array.from(new Set(ns.map(n => String(n.name || n.node || n).trim()).filter(Boolean))) : []); setRows(tbody, []); @@ -149,17 +105,19 @@ export async function startAdminWatches() { })); const htmlRows = rows.map(r => rowHTML([ - ``, + ``, safe(r.sid), safe(r.name), safe(r.node), badge(safe(r.status), /running|online|started/i.test(r.status) ? 'ok' : 'dark'), - ``, - `
- - - -
` + `
+ + + + +
`, + ``, + `` ])); setRows(tbody, htmlRows); @@ -168,7 +126,8 @@ export async function startAdminWatches() { Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { const nodeCell = tr.children[3]; const targetSel = tr.querySelector('.target-node'); - rebuildTargetSelect(targetSel, nodeCell?.textContent.trim(), availableNodes); + const _others = rebuildTargetSelect(targetSel, nodeCell?.textContent.trim(), availableNodes); + updateMigrateButton(tr, /running|online|started/i.test(tr.children[4].innerText)); const sid = tr.getAttribute('data-sid'); const nameCell = tr.children[2]; @@ -178,7 +137,7 @@ export async function startAdminWatches() { const targetNode = needsTarget ? targetSel?.value : undefined; activeSids.add(sid); setBadgeCell(tr.children[4], 'working'); - setMigrateDisabled(tr, false); + updateMigrateButton(tr, false); const res = await api.vmAction(sid, kind, targetNode); if (res?.ok) { showToast(`Task ${kind} started for ${safe(nameCell.textContent)}`); @@ -190,8 +149,10 @@ export async function startAdminWatches() { } } + tr.querySelector('.act-start')?.addEventListener('click', () => doAction('start')); tr.querySelector('.act-stop')?.addEventListener('click', () => doAction('stop')); tr.querySelector('.act-shutdown')?.addEventListener('click', () => doAction('shutdown')); + tr.querySelector('.act-unlock')?.addEventListener('click', () => doAction('unlock')); tr.querySelector('.act-migrate')?.addEventListener('click', () => doAction('migrate', true)); ensureWatchOn(); @@ -221,7 +182,8 @@ export async function startAdminWatches() { const newNode = String(rowData.node || '').trim(); if (nodeCell && newNode && nodeCell.textContent.trim() !== newNode) { nodeCell.textContent = newNode; - rebuildTargetSelect(targetSel, newNode, nodesNow); + const _others = rebuildTargetSelect(targetSel, newNode, nodesNow); + updateMigrateButton(tr, /running|online|started/i.test(tr.children[4].innerText)); flashDot(nameCell); } @@ -232,7 +194,9 @@ export async function startAdminWatches() { if (stRaw) { const changed = setBadgeCell(statusCell, stRaw); const isRunning = /running|online|started/.test(stRaw); - setMigrateDisabled(tr, isRunning); + updateMigrateButton(tr, isRunning); + updateActionButtons(tr, isRunning); + updateActionButtons(tr, isRunning); if (changed) flashDot(nameCell); } } @@ -258,8 +222,10 @@ export async function startAdminWatches() { const stRaw = String((detail.current && (detail.current.status || detail.current.qmpstatus)) || '').toLowerCase(); const changed = setBadgeCell(statusCell, stRaw); - const isRunning = /running|online|started/.test(stRaw); - setMigrateDisabled(tr, isRunning); + const isRunning = /running|online|started/.test(stRaw); + updateMigrateButton(tr, isRunning); + updateActionButtons(tr, isRunning); + updateActionButtons(tr, isRunning); if (changed) flashDot(nameCell); const newNode = String(detail.node || (detail.meta && detail.meta.node) || '').trim(); From 2e32309f3c0c3fe00143e2356dc4a55d712b3845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 23:17:53 +0200 Subject: [PATCH 18/28] refator_comm1 --- static/js/admin.js | 249 ++++++++++++++++++++++++++++----------------- 1 file changed, 153 insertions(+), 96 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index 7c9f1c4..cbc3871 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -1,11 +1,18 @@ import { rowHTML, setRows, safe, showToast, badge } from './helpers.js'; import { api } from './api.js'; +// --- State --- const liveSockets = new Map(); let slowTimer = null; let fastTimer = null; const activeSids = new Set(); +// --- Small helpers (no optional chaining) --- +function qSel(root, sel){ return root ? root.querySelector(sel) : null; } +function text(el){ return (el && el.textContent) ? el.textContent : ''; } +function val(el){ return el ? el.value : undefined; } +function low(x){ return String(x||'').toLowerCase(); } + function injectOnceCSS() { if (document.getElementById('vmadmin-live-css')) return; const style = document.createElement('style'); @@ -22,75 +29,128 @@ function flashDot(cell) { const dot = document.createElement('span'); dot.className = 'pulse-dot'; cell.appendChild(dot); - setTimeout(() => dot.remove(), 1500); + setTimeout(() => { if (dot && dot.parentNode) dot.parentNode.removeChild(dot); }, 1500); } function setBadgeCell(cell, textOrState) { - if (!cell) return; + if (!cell) return false; let html = ''; - const s = String(textOrState || '').toLowerCase(); + const s = low(textOrState); if (/running|online|started/.test(s)) html = badge('running','ok'); else if (/stopp|shutdown|offline/.test(s)) html = badge('stopped','dark'); else if (/working|progress|busy/.test(s)) html = badge('working','info'); else html = badge(textOrState || '—','dark'); - if (cell.innerHTML !== html) { - cell.innerHTML = html; - return true; // changed - } + if (cell.innerHTML !== html) { cell.innerHTML = html; return true; } return false; } function rebuildTargetSelect(selectEl, currentNode, nodes) { if (!selectEl) return []; + const current = String(currentNode || '').trim(); const others = (nodes || []) - .map(n => String(n).trim()) + .map(n => String(n && (n.name || n.node || n)).trim()) .filter(Boolean) - .filter(n => n !== String(currentNode || '').trim()); + .filter(n => n !== current); selectEl.innerHTML = others.map(n => ``).join(''); - if (selectEl.options.length > 0) { - selectEl.selectedIndex = 0; - } - return others; // return list to decide enable/disable of MIGRATE + if (selectEl.options.length > 0) selectEl.selectedIndex = 0; + return others; } function updateMigrateButton(tr, isRunning) { - const btn = tr?.querySelector('.act-migrate'); - const targetSel = tr?.querySelector('.target-node'); + const btn = qSel(tr, '.act-migrate'); + const targetSel = qSel(tr, '.target-node'); + const hasTarget = !!(targetSel && targetSel.options && targetSel.options.length > 0); + const enable = !!(isRunning && hasTarget); if (!btn) return; - const hasTarget = targetSel && targetSel.options && targetSel.options.length > 0; - const enable = isRunning && hasTarget; - if (enable) { - btn.removeAttribute('disabled'); - btn.classList.remove('disabled'); - } else { - btn.setAttribute('disabled', ''); - btn.classList.add('disabled'); - } + if (enable) { btn.removeAttribute('disabled'); btn.classList.remove('disabled'); } + else { btn.setAttribute('disabled',''); btn.classList.add('disabled'); } } function updateActionButtons(tr, isRunning) { - const bStart = tr?.querySelector('.act-start'); - const bStop = tr?.querySelector('.act-stop'); - const bShutdown = tr?.querySelector('.act-shutdown'); - if (bStart) { - if (isRunning) { bStart.setAttribute('disabled',''); bStart.classList.add('disabled'); } - else { bStart.removeAttribute('disabled'); bStart.classList.remove('disabled'); } - } - if (bStop) { - if (isRunning) { bStop.removeAttribute('disabled'); bStop.classList.remove('disabled'); } - else { bStop.setAttribute('disabled',''); bStop.classList.add('disabled'); } - } - if (bShutdown) { - if (isRunning) { bShutdown.removeAttribute('disabled'); bShutdown.classList.remove('disabled'); } - else { bShutdown.setAttribute('disabled',''); bShutdown.classList.add('disabled'); } - } + const bStart = qSel(tr, '.act-start'); + const bStop = qSel(tr, '.act-stop'); + const bShutdown = qSel(tr, '.act-shutdown'); + if (bStart) { if (isRunning) { bStart.setAttribute('disabled',''); bStart.classList.add('disabled'); } else { bStart.removeAttribute('disabled'); bStart.classList.remove('disabled'); } } + if (bStop) { if (isRunning) { bStop.removeAttribute('disabled'); bStop.classList.remove('disabled'); } else { bStop.setAttribute('disabled',''); bStop.classList.add('disabled'); } } + if (bShutdown) { if (isRunning) { bShutdown.removeAttribute('disabled'); bShutdown.classList.remove('disabled'); } else { bShutdown.setAttribute('disabled',''); bShutdown.classList.add('disabled'); } } } - ? Array.from(new Set(ns.nodes.map(n => String(n.name || n.node || n).trim()).filter(Boolean))) - : (Array.isArray(ns) ? Array.from(new Set(ns.map(n => String(n.name || n.node || n).trim()).filter(Boolean))) : []); +export function stopAllAdminWatches() { + liveSockets.forEach(function(ws){ try { ws.close(); } catch(e){} }); + liveSockets.clear(); + if (slowTimer) clearInterval(slowTimer); + if (fastTimer) clearInterval(fastTimer); + slowTimer = null; fastTimer = null; activeSids.clear(); +} + +function ensureWatchOn() { + const tbody = document.querySelector('#vm-admin tbody'); + if (!tbody) return; + const rows = Array.from(tbody.querySelectorAll('tr[data-sid]')); + rows.forEach(function(tr){ + const sid = tr.getAttribute('data-sid'); + if (!sid || liveSockets.has(sid)) return; + try { + const wsProto = (location.protocol === 'https:') ? 'wss' : 'ws'; + const ws = new WebSocket(wsProto + '://' + location.host + '/ws/observe?sid=' + encodeURIComponent(sid)); + liveSockets.set(sid, ws); + ws.onopen = function(){}; + ws.onclose = function(){ liveSockets.delete(sid); }; + ws.onerror = function(){}; + ws.onmessage = function(ev){ + try { + const msg = JSON.parse(ev.data || '{}'); + if (!msg || !msg.type) return; + const tr = tbody.querySelector('tr[data-sid="' + sid + '"]'); + if (!tr) return; + const statusCell = tr.children[4]; + const nameCell = tr.children[2]; + const nodeCell = tr.children[3]; + const targetSel = qSel(tr, '.target-node'); + + if (msg.type === 'status') { + const stRaw = low(msg.status); + const changed = setBadgeCell(statusCell, stRaw); + const isRunning = /running|online|started/.test(stRaw); + updateMigrateButton(tr, isRunning); + updateActionButtons(tr, isRunning); + if (changed) flashDot(nameCell); + if (stRaw && /running|stopped|shutdown/.test(stRaw)) { + setTimeout(function(){ activeSids.delete(sid); }, 3000); + } + } + + if (msg.type === 'node' && msg.node) { + const newNode = String(msg.node).trim(); + if (nodeCell && newNode && text(nodeCell).trim() !== newNode) { + nodeCell.textContent = newNode; + rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); + flashDot(nameCell); + } + } + } catch(e){} + }; + } catch(e){} + }); +} + +function dedupe(arr){ return Array.from(new Set(arr)); } +function extractNodeNames(ns){ + if (!ns) return []; + if (Array.isArray(ns.nodes)) return dedupe(ns.nodes.map(n => String(n.name || n.node || n).trim()).filter(Boolean)); + if (Array.isArray(ns)) return dedupe(ns.map(n => String(n.name || n.node || n).trim()).filter(Boolean)); + return []; +} + +export async function startAdminWatches() { + injectOnceCSS(); + const tbody = document.querySelector('#vm-admin tbody'); + if (!tbody) return; + + const ns = await api.nodesSummary(); + const availableNodes = extractNodeNames(ns); setRows(tbody, []); - // initial table fill try { const latest = await api.listAllVmct(); const all = Array.isArray(latest.all) ? latest.all : []; @@ -105,28 +165,29 @@ function updateActionButtons(tr, isRunning) { })); const htmlRows = rows.map(r => rowHTML([ - ``, + ``, safe(r.sid), safe(r.name), safe(r.node), badge(safe(r.status), /running|online|started/i.test(r.status) ? 'ok' : 'dark'), - `
- - - - + `
+ + + +
`, - ``, - `` + ``, + `` ])); setRows(tbody, htmlRows); - // wire node selects and actions - Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { + // wire per-row + Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(function(tr){ const nodeCell = tr.children[3]; - const targetSel = tr.querySelector('.target-node'); - const _others = rebuildTargetSelect(targetSel, nodeCell?.textContent.trim(), availableNodes); + const targetSel = qSel(tr, '.target-node'); + const currentNode = text(nodeCell).trim(); + rebuildTargetSelect(targetSel, currentNode, availableNodes); updateMigrateButton(tr, /running|online|started/i.test(tr.children[4].innerText)); const sid = tr.getAttribute('data-sid'); @@ -134,103 +195,100 @@ function updateActionButtons(tr, isRunning) { async function doAction(kind, needsTarget) { try { - const targetNode = needsTarget ? targetSel?.value : undefined; + const targetNode = needsTarget ? val(targetSel) : undefined; activeSids.add(sid); setBadgeCell(tr.children[4], 'working'); updateMigrateButton(tr, false); const res = await api.vmAction(sid, kind, targetNode); - if (res?.ok) { - showToast(`Task ${kind} started for ${safe(nameCell.textContent)}`); + if (res && res.ok) { + showToast(`Task ${kind} started for ${safe(text(nameCell))}`); } else { - showToast(`Task ${kind} failed for ${safe(nameCell.textContent)}`, 'danger'); + showToast(`Task ${kind} failed for ${safe(text(nameCell))}`, 'danger'); } } catch (e) { - showToast(`Error: ${e?.message || e}`, 'danger'); + showToast(`Error: ${e && e.message ? e.message : e}`, 'danger'); } } - tr.querySelector('.act-start')?.addEventListener('click', () => doAction('start')); - tr.querySelector('.act-stop')?.addEventListener('click', () => doAction('stop')); - tr.querySelector('.act-shutdown')?.addEventListener('click', () => doAction('shutdown')); - tr.querySelector('.act-unlock')?.addEventListener('click', () => doAction('unlock')); - tr.querySelector('.act-migrate')?.addEventListener('click', () => doAction('migrate', true)); + const bStart = qSel(tr, '.act-start'); if (bStart) bStart.addEventListener('click', function(){ doAction('start'); }); + const bStop = qSel(tr, '.act-stop'); if (bStop) bStop.addEventListener('click', function(){ doAction('stop'); }); + const bShut = qSel(tr, '.act-shutdown'); if (bShut) bShut.addEventListener('click', function(){ doAction('shutdown'); }); + const bUnl = qSel(tr, '.act-unlock'); if (bUnl) bUnl.addEventListener('click', function(){ doAction('unlock'); }); + const bMig = qSel(tr, '.act-migrate'); if (bMig) bMig.addEventListener('click', function(){ doAction('migrate', true); }); ensureWatchOn(); }); window.__nodesCache = availableNodes.slice(); - // full refresh – every 30s - slowTimer = setInterval(async () => { + // slow: full refresh every 30s + slowTimer = setInterval(async function(){ try { const latest = await api.listAllVmct(); const all = Array.isArray(latest.all) ? latest.all : []; const bySid = new Map(all.map(x => [String(x.sid), x])); - const nodesNow = Array.isArray(latest.nodes) ? latest.nodes : window.__nodesCache || []; + const nodesNow = Array.isArray(latest.nodes) ? latest.nodes : (window.__nodesCache || []); window.__nodesCache = nodesNow; - Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { + Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(function(tr){ const sid = tr.getAttribute('data-sid'); const rowData = bySid.get(sid); if (!rowData) return; - const nodeCell = tr.children[3]; const statusCell= tr.children[4]; const nameCell = tr.children[2]; - const targetSel = tr.querySelector('.target-node'); + const targetSel = qSel(tr, '.target-node'); const newNode = String(rowData.node || '').trim(); - if (nodeCell && newNode && nodeCell.textContent.trim() !== newNode) { + if (nodeCell && newNode && text(nodeCell).trim() !== newNode) { nodeCell.textContent = newNode; - const _others = rebuildTargetSelect(targetSel, newNode, nodesNow); - updateMigrateButton(tr, /running|online|started/i.test(tr.children[4].innerText)); + rebuildTargetSelect(targetSel, newNode, nodesNow); flashDot(nameCell); } // status from slow reconcile — only when not 'working' to avoid overruling WS - const currentTxt = (statusCell?.innerText || '').toLowerCase(); + const currentTxt = low((statusCell && statusCell.innerText) || ''); if (!/working/.test(currentTxt)) { - const stRaw = String(rowData.status || '').toLowerCase(); // fallback z /cluster/resources + const stRaw = low(rowData.status || ''); if (stRaw) { const changed = setBadgeCell(statusCell, stRaw); const isRunning = /running|online|started/.test(stRaw); updateMigrateButton(tr, isRunning); updateActionButtons(tr, isRunning); - updateActionButtons(tr, isRunning); if (changed) flashDot(nameCell); } } }); - } catch {} + } catch(e){} }, 30000); - // active only – every 10s (pull precise status + node) - fastTimer = setInterval(async () => { + // fast: active sids every 10s + fastTimer = setInterval(async function(){ try { const sids = Array.from(activeSids); if (!sids.length) return; - for (const sid of sids) { + for (let i=0;i activeSids.delete(sid), 4000); + setTimeout(function(){ activeSids.delete(sid); }, 4000); } } - } catch {} + } catch(e){} }, 10000); window.addEventListener('beforeunload', stopAllAdminWatches, { once: true }); } catch (e) { - showToast(`Failed to load list: ${e?.message || e}`, 'danger'); + showToast(`Failed to load list: ${e && e.message ? e.message : e}`, 'danger'); } } -// Entry point expected by main.js +// Entry point used by main.js export async function renderVMAdmin() { - try { - await startAdminWatches(); - } catch (e) { - showToast(`VM Admin initialization error: ${e?.message || e}`, 'danger'); + try { await startAdminWatches(); } + catch (e) { + showToast(`VM Admin initialization error: ${e && e.message ? e.message : e}`, 'danger'); console.error(e); } } From 807596c9317113164a19e61dfc953725bdba7dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 23:25:51 +0200 Subject: [PATCH 19/28] refator_comm1 --- static/js/admin.js | 54 ++++++++++++++++++++-------------------------- 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index cbc3871..ff992eb 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -170,53 +170,45 @@ export async function startAdminWatches() { safe(r.name), safe(r.node), badge(safe(r.status), /running|online|started/i.test(r.status) ? 'ok' : 'dark'), - `
- - - - + `
+ + + +
`, - ``, - `` + ``, + `` ])); setRows(tbody, htmlRows); // wire per-row - Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(function(tr){ - const nodeCell = tr.children[3]; - const targetSel = qSel(tr, '.target-node'); - const currentNode = text(nodeCell).trim(); - rebuildTargetSelect(targetSel, currentNode, availableNodes); - updateMigrateButton(tr, /running|online|started/i.test(tr.children[4].innerText)); + // (replaced by delegated handler below) + // delegated events for reliability + tbody.addEventListener('click', async function(ev){ + const t = ev.target; + const tr = t && t.closest ? t.closest('tr[data-sid]') : null; + if (!tr) return; const sid = tr.getAttribute('data-sid'); const nameCell = tr.children[2]; - + const targetSel = tr.querySelector('.target-node'); async function doAction(kind, needsTarget) { try { - const targetNode = needsTarget ? val(targetSel) : undefined; + const targetNode = needsTarget ? (targetSel ? targetSel.value : undefined) : undefined; activeSids.add(sid); setBadgeCell(tr.children[4], 'working'); updateMigrateButton(tr, false); const res = await api.vmAction(sid, kind, targetNode); - if (res && res.ok) { - showToast(`Task ${kind} started for ${safe(text(nameCell))}`); - } else { - showToast(`Task ${kind} failed for ${safe(text(nameCell))}`, 'danger'); - } - } catch (e) { - showToast(`Error: ${e && e.message ? e.message : e}`, 'danger'); - } + if (res && res.ok) { showToast(`Task ${kind} started for ${safe(nameCell.textContent)}`); } + else { showToast(`Task ${kind} failed for ${safe(nameCell.textContent)}`, 'danger'); } + } catch(e) { showToast(`Error: ${e && e.message ? e.message : e}`, 'danger'); } } - - const bStart = qSel(tr, '.act-start'); if (bStart) bStart.addEventListener('click', function(){ doAction('start'); }); - const bStop = qSel(tr, '.act-stop'); if (bStop) bStop.addEventListener('click', function(){ doAction('stop'); }); - const bShut = qSel(tr, '.act-shutdown'); if (bShut) bShut.addEventListener('click', function(){ doAction('shutdown'); }); - const bUnl = qSel(tr, '.act-unlock'); if (bUnl) bUnl.addEventListener('click', function(){ doAction('unlock'); }); - const bMig = qSel(tr, '.act-migrate'); if (bMig) bMig.addEventListener('click', function(){ doAction('migrate', true); }); - - ensureWatchOn(); + if (t.matches && t.matches('.act-start')) return doAction('start'); + if (t.matches && t.matches('.act-stop')) return doAction('stop'); + if (t.matches && t.matches('.act-shutdown')) return doAction('shutdown'); + if (t.matches && t.matches('.act-unlock')) return doAction('unlock'); + if (t.matches && t.matches('.act-migrate')) return doAction('migrate', true); }); window.__nodesCache = availableNodes.slice(); From eaa9cfbe8c01b7ccd747c6d813a2c1cee32151ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 23:28:21 +0200 Subject: [PATCH 20/28] refator_comm1 --- static/styles.css | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/static/styles.css b/static/styles.css index 48b454c..0b03b80 100644 --- a/static/styles.css +++ b/static/styles.css @@ -154,4 +154,29 @@ footer.site-footer a:hover { #toast-container .toast { pointer-events: auto; +} + +#vm-admin table { + overflow: visible; +} + +#vm-admin td { + vertical-align: middle; +} + +#vm-admin .target-node { + position: relative; + z-index: 3; +} + +#vm-admin .btn.btn-sm { + line-height: 1.2; +} + +#vm-admin .btn-group .btn { + min-width: 4.2rem; +} + +#vm-admin .act-migrate { + white-space: nowrap; } \ No newline at end of file From 7a4b73ea9364b371c27e55a397ff385a2e8a377d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 23:40:59 +0200 Subject: [PATCH 21/28] refator_comm1 --- static/js/admin.js | 144 ++++++++++++++++++++++++--------------------- 1 file changed, 77 insertions(+), 67 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index ff992eb..2ae615a 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -7,10 +7,10 @@ let slowTimer = null; let fastTimer = null; const activeSids = new Set(); -// --- Small helpers (no optional chaining) --- -function qSel(root, sel){ return root ? root.querySelector(sel) : null; } -function text(el){ return (el && el.textContent) ? el.textContent : ''; } -function val(el){ return el ? el.value : undefined; } +// --- tiny safe helpers --- +function q(root, sel){ return root ? root.querySelector(sel) : null; } +function qq(root, sel){ return root ? Array.from(root.querySelectorAll(sel)) : []; } +function txt(el){ return (el && el.textContent) ? el.textContent : ''; } function low(x){ return String(x||'').toLowerCase(); } function injectOnceCSS() { @@ -29,7 +29,7 @@ function flashDot(cell) { const dot = document.createElement('span'); dot.className = 'pulse-dot'; cell.appendChild(dot); - setTimeout(() => { if (dot && dot.parentNode) dot.parentNode.removeChild(dot); }, 1500); + setTimeout(function(){ if (dot && dot.parentNode) dot.parentNode.removeChild(dot); }, 1500); } function setBadgeCell(cell, textOrState) { @@ -44,21 +44,33 @@ function setBadgeCell(cell, textOrState) { return false; } +function extractNodeNames(ns){ + if (!ns) return []; + if (Array.isArray(ns.nodes)) return Array.from(new Set(ns.nodes.map(n => String(n.name || n.node || n).trim()).filter(Boolean))); + if (Array.isArray(ns)) return Array.from(new Set(ns.map(n => String(n.name || n.node || n).trim()).filter(Boolean))); + return []; +} + function rebuildTargetSelect(selectEl, currentNode, nodes) { if (!selectEl) return []; const current = String(currentNode || '').trim(); - const others = (nodes || []) - .map(n => String(n && (n.name || n.node || n)).trim()) - .filter(Boolean) - .filter(n => n !== current); + const all = (nodes || []).map(n => String(n && (n.name || n.node || n)).trim()).filter(Boolean); + const others = all.filter(n => n !== current); selectEl.innerHTML = others.map(n => ``).join(''); - if (selectEl.options.length > 0) selectEl.selectedIndex = 0; + // default: pick the "second node" if exists, otherwise first available other + var idx = 0; + if (all.length >= 2) { + const preferred = all[1]; + const j = others.indexOf(preferred); + if (j >= 0) idx = j; + } + if (selectEl.options.length > 0) selectEl.selectedIndex = idx; return others; } function updateMigrateButton(tr, isRunning) { - const btn = qSel(tr, '.act-migrate'); - const targetSel = qSel(tr, '.target-node'); + const btn = q(tr, '.act-migrate'); + const targetSel = q(tr, '.target-node'); const hasTarget = !!(targetSel && targetSel.options && targetSel.options.length > 0); const enable = !!(isRunning && hasTarget); if (!btn) return; @@ -67,9 +79,9 @@ function updateMigrateButton(tr, isRunning) { } function updateActionButtons(tr, isRunning) { - const bStart = qSel(tr, '.act-start'); - const bStop = qSel(tr, '.act-stop'); - const bShutdown = qSel(tr, '.act-shutdown'); + const bStart = q(tr, '.act-start'); + const bStop = q(tr, '.act-stop'); + const bShutdown = q(tr, '.act-shutdown'); if (bStart) { if (isRunning) { bStart.setAttribute('disabled',''); bStart.classList.add('disabled'); } else { bStart.removeAttribute('disabled'); bStart.classList.remove('disabled'); } } if (bStop) { if (isRunning) { bStop.removeAttribute('disabled'); bStop.classList.remove('disabled'); } else { bStop.setAttribute('disabled',''); bStop.classList.add('disabled'); } } if (bShutdown) { if (isRunning) { bShutdown.removeAttribute('disabled'); bShutdown.classList.remove('disabled'); } else { bShutdown.setAttribute('disabled',''); bShutdown.classList.add('disabled'); } } @@ -86,8 +98,7 @@ export function stopAllAdminWatches() { function ensureWatchOn() { const tbody = document.querySelector('#vm-admin tbody'); if (!tbody) return; - const rows = Array.from(tbody.querySelectorAll('tr[data-sid]')); - rows.forEach(function(tr){ + qq(tbody, 'tr[data-sid]').forEach(function(tr){ const sid = tr.getAttribute('data-sid'); if (!sid || liveSockets.has(sid)) return; try { @@ -101,19 +112,19 @@ function ensureWatchOn() { try { const msg = JSON.parse(ev.data || '{}'); if (!msg || !msg.type) return; - const tr = tbody.querySelector('tr[data-sid="' + sid + '"]'); - if (!tr) return; - const statusCell = tr.children[4]; - const nameCell = tr.children[2]; - const nodeCell = tr.children[3]; - const targetSel = qSel(tr, '.target-node'); + const tr2 = tbody.querySelector('tr[data-sid="' + sid + '"]'); + if (!tr2) return; + const statusCell = tr2.children[4]; + const nameCell = tr2.children[2]; + const nodeCell = tr2.children[3]; + const targetSel = q(tr2, '.target-node'); if (msg.type === 'status') { const stRaw = low(msg.status); const changed = setBadgeCell(statusCell, stRaw); const isRunning = /running|online|started/.test(stRaw); - updateMigrateButton(tr, isRunning); - updateActionButtons(tr, isRunning); + updateMigrateButton(tr2, isRunning); + updateActionButtons(tr2, isRunning); if (changed) flashDot(nameCell); if (stRaw && /running|stopped|shutdown/.test(stRaw)) { setTimeout(function(){ activeSids.delete(sid); }, 3000); @@ -122,7 +133,7 @@ function ensureWatchOn() { if (msg.type === 'node' && msg.node) { const newNode = String(msg.node).trim(); - if (nodeCell && newNode && text(nodeCell).trim() !== newNode) { + if (nodeCell && newNode && txt(nodeCell).trim() !== newNode) { nodeCell.textContent = newNode; rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); flashDot(nameCell); @@ -134,14 +145,6 @@ function ensureWatchOn() { }); } -function dedupe(arr){ return Array.from(new Set(arr)); } -function extractNodeNames(ns){ - if (!ns) return []; - if (Array.isArray(ns.nodes)) return dedupe(ns.nodes.map(n => String(n.name || n.node || n).trim()).filter(Boolean)); - if (Array.isArray(ns)) return dedupe(ns.map(n => String(n.name || n.node || n).trim()).filter(Boolean)); - return []; -} - export async function startAdminWatches() { injectOnceCSS(); const tbody = document.querySelector('#vm-admin tbody'); @@ -170,29 +173,37 @@ export async function startAdminWatches() { safe(r.name), safe(r.node), badge(safe(r.status), /running|online|started/i.test(r.status) ? 'ok' : 'dark'), - `
- - - - + `
+ + + +
`, - ``, - `` + ``, + `` ])); setRows(tbody, htmlRows); - // wire per-row - // (replaced by delegated handler below) + // prepare rows + qq(tbody, 'tr[data-sid]').forEach(function(tr){ + const nodeCell = tr.children[3]; + const targetSel = q(tr, '.target-node'); + const currentNode = txt(nodeCell).trim(); + rebuildTargetSelect(targetSel, currentNode, availableNodes); + updateMigrateButton(tr, /running|online|started/i.test(tr.children[4].innerText)); + }); - // delegated events for reliability + // delegated events (reliable after refreshes) tbody.addEventListener('click', async function(ev){ const t = ev.target; - const tr = t && t.closest ? t.closest('tr[data-sid]') : null; + const btn = t && t.closest ? t.closest('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate') : null; + if (!btn) return; + const tr = btn.closest ? btn.closest('tr[data-sid]') : null; if (!tr) return; const sid = tr.getAttribute('data-sid'); const nameCell = tr.children[2]; - const targetSel = tr.querySelector('.target-node'); + const targetSel = q(tr, '.target-node'); async function doAction(kind, needsTarget) { try { const targetNode = needsTarget ? (targetSel ? targetSel.value : undefined) : undefined; @@ -200,45 +211,44 @@ export async function startAdminWatches() { setBadgeCell(tr.children[4], 'working'); updateMigrateButton(tr, false); const res = await api.vmAction(sid, kind, targetNode); - if (res && res.ok) { showToast(`Task ${kind} started for ${safe(nameCell.textContent)}`); } - else { showToast(`Task ${kind} failed for ${safe(nameCell.textContent)}`, 'danger'); } + if (res && res.ok) { showToast(`Task ${kind} started for ${safe(txt(nameCell))}`); } + else { showToast(`Task ${kind} failed for ${safe(txt(nameCell))}`, 'danger'); } } catch(e) { showToast(`Error: ${e && e.message ? e.message : e}`, 'danger'); } } - if (t.matches && t.matches('.act-start')) return doAction('start'); - if (t.matches && t.matches('.act-stop')) return doAction('stop'); - if (t.matches && t.matches('.act-shutdown')) return doAction('shutdown'); - if (t.matches && t.matches('.act-unlock')) return doAction('unlock'); - if (t.matches && t.matches('.act-migrate')) return doAction('migrate', true); + if (btn.classList.contains('act-start')) return doAction('start'); + if (btn.classList.contains('act-stop')) return doAction('stop'); + if (btn.classList.contains('act-shutdown')) return doAction('shutdown'); + if (btn.classList.contains('act-unlock')) return doAction('unlock'); + if (btn.classList.contains('act-migrate')) return doAction('migrate', true); }); window.__nodesCache = availableNodes.slice(); - // slow: full refresh every 30s + // slow full refresh slowTimer = setInterval(async function(){ try { - const latest = await api.listAllVmct(); - const all = Array.isArray(latest.all) ? latest.all : []; - const bySid = new Map(all.map(x => [String(x.sid), x])); - const nodesNow = Array.isArray(latest.nodes) ? latest.nodes : (window.__nodesCache || []); - window.__nodesCache = nodesNow; + const latest2 = await api.listAllVmct(); + const all2 = Array.isArray(latest2.all) ? latest2.all : []; + const bySid = new Map(all2.map(x => [String(x.sid), x])); + const nodesNow2 = Array.isArray(latest2.nodes) ? latest2.nodes : (window.__nodesCache || []); + window.__nodesCache = nodesNow2; - Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(function(tr){ + qq(tbody, 'tr[data-sid]').forEach(function(tr){ const sid = tr.getAttribute('data-sid'); const rowData = bySid.get(sid); if (!rowData) return; const nodeCell = tr.children[3]; const statusCell= tr.children[4]; const nameCell = tr.children[2]; - const targetSel = qSel(tr, '.target-node'); + const targetSel = q(tr, '.target-node'); const newNode = String(rowData.node || '').trim(); - if (nodeCell && newNode && text(nodeCell).trim() !== newNode) { + if (nodeCell && newNode && txt(nodeCell).trim() !== newNode) { nodeCell.textContent = newNode; - rebuildTargetSelect(targetSel, newNode, nodesNow); + rebuildTargetSelect(targetSel, newNode, nodesNow2); flashDot(nameCell); } - // status from slow reconcile — only when not 'working' to avoid overruling WS const currentTxt = low((statusCell && statusCell.innerText) || ''); if (!/working/.test(currentTxt)) { const stRaw = low(rowData.status || ''); @@ -254,7 +264,7 @@ export async function startAdminWatches() { } catch(e){} }, 30000); - // fast: active sids every 10s + // fast refresh: active sids fastTimer = setInterval(async function(){ try { const sids = Array.from(activeSids); @@ -269,7 +279,7 @@ export async function startAdminWatches() { const nodeCell = tr.children[3]; const statusCell= tr.children[4]; const nameCell = tr.children[2]; - const targetSel = qSel(tr, '.target-node'); + const targetSel = q(tr, '.target-node'); const stRaw = low((detail.current && (detail.current.status || detail.current.qmpstatus)) || ''); const changed = setBadgeCell(statusCell, stRaw); @@ -280,7 +290,7 @@ export async function startAdminWatches() { const newNode = String(detail.node || (detail.meta && detail.meta.node) || '').trim(); if (newNode) { - if (nodeCell && text(nodeCell).trim() !== newNode) { + if (nodeCell && txt(nodeCell).trim() !== newNode) { nodeCell.textContent = newNode; rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); flashDot(nameCell); From 5149ce140c2135db3b27be9ab3f591bb65a7edf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 23:46:21 +0200 Subject: [PATCH 22/28] refator_comm1 --- static/js/admin.js | 62 +++++++++++++++++++++++++--------------------- static/styles.css | 33 +++++++++++------------- 2 files changed, 48 insertions(+), 47 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index 2ae615a..572938d 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -37,7 +37,7 @@ function setBadgeCell(cell, textOrState) { let html = ''; const s = low(textOrState); if (/running|online|started/.test(s)) html = badge('running','ok'); - else if (/stopp|shutdown|offline/.test(s)) html = badge('stopped','dark'); + else if (/stopp|shutdown|offline|stopped/.test(s)) html = badge('stopped','dark'); else if (/working|progress|busy/.test(s)) html = badge('working','info'); else html = badge(textOrState || '—','dark'); if (cell.innerHTML !== html) { cell.innerHTML = html; return true; } @@ -55,25 +55,22 @@ function rebuildTargetSelect(selectEl, currentNode, nodes) { if (!selectEl) return []; const current = String(currentNode || '').trim(); const all = (nodes || []).map(n => String(n && (n.name || n.node || n)).trim()).filter(Boolean); - const others = all.filter(n => n !== current); + const others = all.filter(n => n && n !== current); selectEl.innerHTML = others.map(n => ``).join(''); - // default: pick the "second node" if exists, otherwise first available other - var idx = 0; - if (all.length >= 2) { - const preferred = all[1]; - const j = others.indexOf(preferred); - if (j >= 0) idx = j; - } - if (selectEl.options.length > 0) selectEl.selectedIndex = idx; + // wybierz pierwszy sensowny inny node + if (selectEl.options.length > 0) selectEl.selectedIndex = 0; return others; } -function updateMigrateButton(tr, isRunning) { +function updateMigrateButton(tr, rawStatus) { const btn = q(tr, '.act-migrate'); const targetSel = q(tr, '.target-node'); - const hasTarget = !!(targetSel && targetSel.options && targetSel.options.length > 0); - const enable = !!(isRunning && hasTarget); if (!btn) return; + const hasTarget = !!(targetSel && targetSel.options && targetSel.options.length > 0); + const s = low(rawStatus || ''); + const busy = /working|progress|busy/.test(s); + // pozwól offline i online migrate — byle jest docelowy węzeł i nie trwa inna akcja + const enable = hasTarget && !busy && s !== ''; if (enable) { btn.removeAttribute('disabled'); btn.classList.remove('disabled'); } else { btn.setAttribute('disabled',''); btn.classList.add('disabled'); } } @@ -119,11 +116,13 @@ function ensureWatchOn() { const nodeCell = tr2.children[3]; const targetSel = q(tr2, '.target-node'); - if (msg.type === 'status') { - const stRaw = low(msg.status); + // dopasowane do serwera: type="vm" i "moved" + if (msg.type === 'vm') { + const cur = msg.current || {}; + const stRaw = low(cur.status || cur.qmpstatus || ''); const changed = setBadgeCell(statusCell, stRaw); const isRunning = /running|online|started/.test(stRaw); - updateMigrateButton(tr2, isRunning); + updateMigrateButton(tr2, stRaw); updateActionButtons(tr2, isRunning); if (changed) flashDot(nameCell); if (stRaw && /running|stopped|shutdown/.test(stRaw)) { @@ -131,8 +130,8 @@ function ensureWatchOn() { } } - if (msg.type === 'node' && msg.node) { - const newNode = String(msg.node).trim(); + if (msg.type === 'moved' && msg.new_node) { + const newNode = String(msg.new_node).trim(); if (nodeCell && newNode && txt(nodeCell).trim() !== newNode) { nodeCell.textContent = newNode; rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); @@ -191,7 +190,10 @@ export async function startAdminWatches() { const targetSel = q(tr, '.target-node'); const currentNode = txt(nodeCell).trim(); rebuildTargetSelect(targetSel, currentNode, availableNodes); - updateMigrateButton(tr, /running|online|started/i.test(tr.children[4].innerText)); + const stRaw = low(tr.children[4].innerText); + const isRunning = /running|online|started/.test(stRaw); + updateMigrateButton(tr, stRaw); + updateActionButtons(tr, isRunning); }); // delegated events (reliable after refreshes) @@ -199,21 +201,24 @@ export async function startAdminWatches() { const t = ev.target; const btn = t && t.closest ? t.closest('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate') : null; if (!btn) return; + ev.preventDefault(); ev.stopPropagation(); // <— kluczowe, żeby zawsze doszedł POST const tr = btn.closest ? btn.closest('tr[data-sid]') : null; if (!tr) return; const sid = tr.getAttribute('data-sid'); const nameCell = tr.children[2]; + const statusCell = tr.children[4]; const targetSel = q(tr, '.target-node'); + async function doAction(kind, needsTarget) { try { const targetNode = needsTarget ? (targetSel ? targetSel.value : undefined) : undefined; activeSids.add(sid); - setBadgeCell(tr.children[4], 'working'); - updateMigrateButton(tr, false); + setBadgeCell(statusCell, 'working'); + updateMigrateButton(tr, 'working'); const res = await api.vmAction(sid, kind, targetNode); - if (res && res.ok) { showToast(`Task ${kind} started for ${safe(txt(nameCell))}`); } - else { showToast(`Task ${kind} failed for ${safe(txt(nameCell))}`, 'danger'); } - } catch(e) { showToast(`Error: ${e && e.message ? e.message : e}`, 'danger'); } + if (res && res.ok) { showToast('OK', `Task ${kind} started for ${safe(txt(nameCell))}`, 'success'); } + else { showToast('Error', `Task ${kind} failed for ${safe(txt(nameCell))}`, 'danger'); } + } catch(e) { showToast('Error', String(e && e.message ? e.message : e), 'danger'); } } if (btn.classList.contains('act-start')) return doAction('start'); if (btn.classList.contains('act-stop')) return doAction('stop'); @@ -255,7 +260,7 @@ export async function startAdminWatches() { if (stRaw) { const changed = setBadgeCell(statusCell, stRaw); const isRunning = /running|online|started/.test(stRaw); - updateMigrateButton(tr, isRunning); + updateMigrateButton(tr, stRaw); updateActionButtons(tr, isRunning); if (changed) flashDot(nameCell); } @@ -284,7 +289,7 @@ export async function startAdminWatches() { const stRaw = low((detail.current && (detail.current.status || detail.current.qmpstatus)) || ''); const changed = setBadgeCell(statusCell, stRaw); const isRunning = /running|online|started/.test(stRaw); - updateMigrateButton(tr, isRunning); + updateMigrateButton(tr, stRaw); updateActionButtons(tr, isRunning); if (changed) flashDot(nameCell); @@ -304,9 +309,10 @@ export async function startAdminWatches() { } catch(e){} }, 10000); + ensureWatchOn(); window.addEventListener('beforeunload', stopAllAdminWatches, { once: true }); } catch (e) { - showToast(`Failed to load list: ${e && e.message ? e.message : e}`, 'danger'); + showToast('Error', `Failed to load list: ${e && e.message ? e.message : e}`, 'danger'); } } @@ -314,7 +320,7 @@ export async function startAdminWatches() { export async function renderVMAdmin() { try { await startAdminWatches(); } catch (e) { - showToast(`VM Admin initialization error: ${e && e.message ? e.message : e}`, 'danger'); + showToast('Error', `VM Admin initialization error: ${e && e.message ? e.message : e}`, 'danger'); console.error(e); } } diff --git a/static/styles.css b/static/styles.css index 0b03b80..9738582 100644 --- a/static/styles.css +++ b/static/styles.css @@ -144,17 +144,6 @@ footer.site-footer a:hover { } } -#toast-container { - position: fixed; - right: max(env(safe-area-inset-right), 1rem); - bottom: max(env(safe-area-inset-bottom), 1rem); - z-index: 1080; - pointer-events: none; -} - -#toast-container .toast { - pointer-events: auto; -} #vm-admin table { overflow: visible; @@ -169,14 +158,20 @@ footer.site-footer a:hover { z-index: 3; } -#vm-admin .btn.btn-sm { - line-height: 1.2; +#toast-container { + position: fixed; + right: max(env(safe-area-inset-right), 1rem); + bottom: max(env(safe-area-inset-bottom), 1rem); + z-index: 1080; + pointer-events: none; + width: min(480px, 96vw); + max-width: min(480px, 96vw); } -#vm-admin .btn-group .btn { - min-width: 4.2rem; -} - -#vm-admin .act-migrate { - white-space: nowrap; +#toast-container .toast { + pointer-events: auto; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; + white-space: normal; } \ No newline at end of file From 88f42687bf9668ba8c5087c28765c85812785744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 18 Oct 2025 23:51:21 +0200 Subject: [PATCH 23/28] refator_comm1 --- static/js/admin.js | 419 ++++++++++++++------------------------------- static/styles.css | 21 ++- 2 files changed, 139 insertions(+), 301 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index 572938d..666c460 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -1,326 +1,155 @@ import { rowHTML, setRows, safe, showToast, badge } from './helpers.js'; import { api } from './api.js'; -// --- State --- -const liveSockets = new Map(); -let slowTimer = null; -let fastTimer = null; -const activeSids = new Set(); - -// --- tiny safe helpers --- -function q(root, sel){ return root ? root.querySelector(sel) : null; } -function qq(root, sel){ return root ? Array.from(root.querySelectorAll(sel)) : []; } -function txt(el){ return (el && el.textContent) ? el.textContent : ''; } -function low(x){ return String(x||'').toLowerCase(); } - -function injectOnceCSS() { - if (document.getElementById('vmadmin-live-css')) return; - const style = document.createElement('style'); - style.id = 'vmadmin-live-css'; - style.textContent = ` - .pulse-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#22c55e;margin-left:.5rem;opacity:.25;animation:pulse-vm 1s ease-in-out 4} - @keyframes pulse-vm {0%,100%{opacity:.25;transform:scale(1)}50%{opacity:1;transform:scale(1.25)}} - `; - document.head.appendChild(style); -} - -function flashDot(cell) { - if (!cell) return; - const dot = document.createElement('span'); - dot.className = 'pulse-dot'; - cell.appendChild(dot); - setTimeout(function(){ if (dot && dot.parentNode) dot.parentNode.removeChild(dot); }, 1500); -} - -function setBadgeCell(cell, textOrState) { - if (!cell) return false; - let html = ''; - const s = low(textOrState); - if (/running|online|started/.test(s)) html = badge('running','ok'); - else if (/stopp|shutdown|offline|stopped/.test(s)) html = badge('stopped','dark'); - else if (/working|progress|busy/.test(s)) html = badge('working','info'); - else html = badge(textOrState || '—','dark'); - if (cell.innerHTML !== html) { cell.innerHTML = html; return true; } - return false; -} - -function extractNodeNames(ns){ - if (!ns) return []; - if (Array.isArray(ns.nodes)) return Array.from(new Set(ns.nodes.map(n => String(n.name || n.node || n).trim()).filter(Boolean))); - if (Array.isArray(ns)) return Array.from(new Set(ns.map(n => String(n.name || n.node || n).trim()).filter(Boolean))); - return []; -} +// helpers +const q = (r, s) => (r || document).querySelector(s); +const qq = (r, s) => Array.from((r || document).querySelectorAll(s)); +const low = (x) => String(x ?? '').toLowerCase(); +// --- dropdown: wybierz pierwszy inny node od bieżącego function rebuildTargetSelect(selectEl, currentNode, nodes) { - if (!selectEl) return []; - const current = String(currentNode || '').trim(); - const all = (nodes || []).map(n => String(n && (n.name || n.node || n)).trim()).filter(Boolean); - const others = all.filter(n => n && n !== current); + if (!selectEl) return; + const cur = String(currentNode || '').trim(); + const all = (nodes || []).map(n => String(n.name || n.node || n).trim()).filter(Boolean); + const others = all.filter(n => n && n !== cur); selectEl.innerHTML = others.map(n => ``).join(''); - // wybierz pierwszy sensowny inny node + // domyślnie pierwszy sensowny inny node if (selectEl.options.length > 0) selectEl.selectedIndex = 0; - return others; } -function updateMigrateButton(tr, rawStatus) { - const btn = q(tr, '.act-migrate'); - const targetSel = q(tr, '.target-node'); - if (!btn) return; - const hasTarget = !!(targetSel && targetSel.options && targetSel.options.length > 0); - const s = low(rawStatus || ''); - const busy = /working|progress|busy/.test(s); - // pozwól offline i online migrate — byle jest docelowy węzeł i nie trwa inna akcja - const enable = hasTarget && !busy && s !== ''; - if (enable) { btn.removeAttribute('disabled'); btn.classList.remove('disabled'); } - else { btn.setAttribute('disabled',''); btn.classList.add('disabled'); } +// --- statusy + przyciski +function boolRunning(statusRaw) { return /running|online|started/i.test(statusRaw || ''); } +function setButtonsByStatus(tr, statusRaw) { + const running = boolRunning(statusRaw); + // start tylko gdy nie działa + const bStart = tr.querySelector('.act-start'); + const bStop = tr.querySelector('.act-stop'); + const bShutdown = tr.querySelector('.act-shutdown'); + + if (bStart) bStart.disabled = running; + if (bStop) bStop.disabled = !running; + if (bShutdown) bShutdown.disabled = !running; + + // MIGRATE: aktywny także dla offline (offline migrate) + const bMig = tr.querySelector('.act-migrate'); + const sel = tr.querySelector('.target-node'); + const hasTarget = !!(sel && sel.value); + // blokujemy tylko gdy nie ma targetu albo trwa „working/progress” + const busy = /working|progress|busy/i.test(statusRaw || ''); + if (bMig) bMig.disabled = !hasTarget || busy; } -function updateActionButtons(tr, isRunning) { - const bStart = q(tr, '.act-start'); - const bStop = q(tr, '.act-stop'); - const bShutdown = q(tr, '.act-shutdown'); - if (bStart) { if (isRunning) { bStart.setAttribute('disabled',''); bStart.classList.add('disabled'); } else { bStart.removeAttribute('disabled'); bStart.classList.remove('disabled'); } } - if (bStop) { if (isRunning) { bStop.removeAttribute('disabled'); bStop.classList.remove('disabled'); } else { bStop.setAttribute('disabled',''); bStop.classList.add('disabled'); } } - if (bShutdown) { if (isRunning) { bShutdown.removeAttribute('disabled'); bShutdown.classList.remove('disabled'); } else { bShutdown.setAttribute('disabled',''); bShutdown.classList.add('disabled'); } } -} - -export function stopAllAdminWatches() { - liveSockets.forEach(function(ws){ try { ws.close(); } catch(e){} }); - liveSockets.clear(); - if (slowTimer) clearInterval(slowTimer); - if (fastTimer) clearInterval(fastTimer); - slowTimer = null; fastTimer = null; activeSids.clear(); -} - -function ensureWatchOn() { - const tbody = document.querySelector('#vm-admin tbody'); - if (!tbody) return; - qq(tbody, 'tr[data-sid]').forEach(function(tr){ - const sid = tr.getAttribute('data-sid'); - if (!sid || liveSockets.has(sid)) return; - try { - const wsProto = (location.protocol === 'https:') ? 'wss' : 'ws'; - const ws = new WebSocket(wsProto + '://' + location.host + '/ws/observe?sid=' + encodeURIComponent(sid)); - liveSockets.set(sid, ws); - ws.onopen = function(){}; - ws.onclose = function(){ liveSockets.delete(sid); }; - ws.onerror = function(){}; - ws.onmessage = function(ev){ - try { - const msg = JSON.parse(ev.data || '{}'); - if (!msg || !msg.type) return; - const tr2 = tbody.querySelector('tr[data-sid="' + sid + '"]'); - if (!tr2) return; - const statusCell = tr2.children[4]; - const nameCell = tr2.children[2]; - const nodeCell = tr2.children[3]; - const targetSel = q(tr2, '.target-node'); - - // dopasowane do serwera: type="vm" i "moved" - if (msg.type === 'vm') { - const cur = msg.current || {}; - const stRaw = low(cur.status || cur.qmpstatus || ''); - const changed = setBadgeCell(statusCell, stRaw); - const isRunning = /running|online|started/.test(stRaw); - updateMigrateButton(tr2, stRaw); - updateActionButtons(tr2, isRunning); - if (changed) flashDot(nameCell); - if (stRaw && /running|stopped|shutdown/.test(stRaw)) { - setTimeout(function(){ activeSids.delete(sid); }, 3000); - } - } - - if (msg.type === 'moved' && msg.new_node) { - const newNode = String(msg.new_node).trim(); - if (nodeCell && newNode && txt(nodeCell).trim() !== newNode) { - nodeCell.textContent = newNode; - rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); - flashDot(nameCell); - } - } - } catch(e){} - }; - } catch(e){} - }); -} - -export async function startAdminWatches() { - injectOnceCSS(); - const tbody = document.querySelector('#vm-admin tbody'); +// --- render +export async function renderVMAdmin() { + const wrap = document.getElementById('vm-admin'); + if (!wrap) return; + const tbody = wrap.querySelector('tbody'); if (!tbody) return; - const ns = await api.nodesSummary(); - const availableNodes = extractNodeNames(ns); - setRows(tbody, []); + // pobierz listy + const [list, nodesSummary] = await Promise.all([api.listAllVmct(), api.nodesSummary()]); + const all = Array.isArray(list.all) ? list.all : []; + const nodes = Array.isArray(nodesSummary?.nodes) ? nodesSummary.nodes : []; - try { - const latest = await api.listAllVmct(); - const all = Array.isArray(latest.all) ? latest.all : []; - const nodesNow = Array.isArray(latest.nodes) ? latest.nodes : []; - window.__nodesCache = nodesNow.slice(); - - const rows = all.map(vm => ({ - sid: safe(vm.sid), - name: safe(vm.name || vm.vmid || vm.sid), - node: safe(vm.node || '—'), - status: safe(vm.status || '—') - })); - - const htmlRows = rows.map(r => rowHTML([ - ``, - safe(r.sid), - safe(r.name), - safe(r.node), - badge(safe(r.status), /running|online|started/i.test(r.status) ? 'ok' : 'dark'), + // zbuduj wiersze (bez checkboxa) + const rows = all.map(vm => { + const status = safe(vm.status || vm.current?.status || vm.current?.qmpstatus || '—'); + const isRun = boolRunning(status); + return rowHTML([ + safe(vm.sid), + safe(vm.name || vm.vmid || vm.sid), + safe(vm.node || '—'), + badge(status, isRun ? 'ok' : 'dark'), `
`, - ``, - `` - ])); + `
+ + +
` + ], `data-sid="${safe(vm.sid)}"`); + }); - setRows(tbody, htmlRows); + setRows(tbody, rows); - // prepare rows - qq(tbody, 'tr[data-sid]').forEach(function(tr){ - const nodeCell = tr.children[3]; - const targetSel = q(tr, '.target-node'); - const currentNode = txt(nodeCell).trim(); - rebuildTargetSelect(targetSel, currentNode, availableNodes); - const stRaw = low(tr.children[4].innerText); - const isRunning = /running|online|started/.test(stRaw); - updateMigrateButton(tr, stRaw); - updateActionButtons(tr, isRunning); - }); + // uzupełnij selecty + stany przycisków + qq(tbody, 'tr[data-sid]').forEach(tr => { + const nodeCell = tr.children[2]; + const statusCell= tr.children[3]; + const sel = tr.querySelector('.target-node'); + rebuildTargetSelect(sel, nodeCell?.textContent?.trim(), nodes); + setButtonsByStatus(tr, statusCell?.innerText || ''); + }); - // delegated events (reliable after refreshes) - tbody.addEventListener('click', async function(ev){ - const t = ev.target; - const btn = t && t.closest ? t.closest('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate') : null; - if (!btn) return; - ev.preventDefault(); ev.stopPropagation(); // <— kluczowe, żeby zawsze doszedł POST - const tr = btn.closest ? btn.closest('tr[data-sid]') : null; - if (!tr) return; - const sid = tr.getAttribute('data-sid'); - const nameCell = tr.children[2]; - const statusCell = tr.children[4]; - const targetSel = q(tr, '.target-node'); + // --- delegacja CLICK globalnie: łapie też elementy po rerenderach + document.addEventListener('click', async (ev) => { + const btn = ev.target.closest?.('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate'); + if (!btn) return; + // zapobiegamy submitom, bąbelkowaniu itp. + ev.preventDefault(); + ev.stopPropagation(); - async function doAction(kind, needsTarget) { - try { - const targetNode = needsTarget ? (targetSel ? targetSel.value : undefined) : undefined; - activeSids.add(sid); - setBadgeCell(statusCell, 'working'); - updateMigrateButton(tr, 'working'); - const res = await api.vmAction(sid, kind, targetNode); - if (res && res.ok) { showToast('OK', `Task ${kind} started for ${safe(txt(nameCell))}`, 'success'); } - else { showToast('Error', `Task ${kind} failed for ${safe(txt(nameCell))}`, 'danger'); } - } catch(e) { showToast('Error', String(e && e.message ? e.message : e), 'danger'); } + const tr = btn.closest('tr[data-sid]'); + if (!tr) return; + const sid = tr.getAttribute('data-sid'); + const name = tr.children[1]?.textContent?.trim() || sid; + + // mapowanie akcji + let action = ''; + let target = undefined; + if (btn.classList.contains('act-start')) action = 'start'; + if (btn.classList.contains('act-stop')) action = 'stop'; + if (btn.classList.contains('act-shutdown')) action = 'shutdown'; + if (btn.classList.contains('act-unlock')) action = 'unlock'; + if (btn.classList.contains('act-migrate')) { + action = 'migrate'; + target = tr.querySelector('.target-node')?.value; + if (!target) { + showToast('Migrate', 'Wybierz docelowy node inny niż bieżący', 'warning'); + return; } - if (btn.classList.contains('act-start')) return doAction('start'); - if (btn.classList.contains('act-stop')) return doAction('stop'); - if (btn.classList.contains('act-shutdown')) return doAction('shutdown'); - if (btn.classList.contains('act-unlock')) return doAction('unlock'); - if (btn.classList.contains('act-migrate')) return doAction('migrate', true); - }); + } - window.__nodesCache = availableNodes.slice(); + try { + const res = await api.vmAction(sid, action, target); + if (res?.ok) { + showToast('OK', `${action.toUpperCase()} × ${name}`, 'success'); + } else { + showToast('Błąd', res?.error || `Nie udało się: ${action}`, 'danger'); + } + } catch (e) { + showToast('Błąd', String(e?.message || e), 'danger'); + } + }, { capture: true }); // capture: pewność, że nasz handler zadziała przed innymi - // slow full refresh - slowTimer = setInterval(async function(){ - try { - const latest2 = await api.listAllVmct(); - const all2 = Array.isArray(latest2.all) ? latest2.all : []; - const bySid = new Map(all2.map(x => [String(x.sid), x])); - const nodesNow2 = Array.isArray(latest2.nodes) ? latest2.nodes : (window.__nodesCache || []); - window.__nodesCache = nodesNow2; + // prosty, lekki refresh co 30s (statusy i node) + setInterval(async () => { + try { + const latest = await api.listAllVmct(); + const bySid = new Map((latest?.all || []).map(x => [String(x.sid), x])); + qq(tbody, 'tr[data-sid]').forEach(tr => { + const sid = tr.getAttribute('data-sid'); + const row = bySid.get(sid); + if (!row) return; + const nodeCell = tr.children[2]; + const statusCell = tr.children[3]; + const sel = tr.querySelector('.target-node'); - qq(tbody, 'tr[data-sid]').forEach(function(tr){ - const sid = tr.getAttribute('data-sid'); - const rowData = bySid.get(sid); - if (!rowData) return; - const nodeCell = tr.children[3]; - const statusCell= tr.children[4]; - const nameCell = tr.children[2]; - const targetSel = q(tr, '.target-node'); - - const newNode = String(rowData.node || '').trim(); - if (nodeCell && newNode && txt(nodeCell).trim() !== newNode) { - nodeCell.textContent = newNode; - rebuildTargetSelect(targetSel, newNode, nodesNow2); - flashDot(nameCell); - } - - const currentTxt = low((statusCell && statusCell.innerText) || ''); - if (!/working/.test(currentTxt)) { - const stRaw = low(rowData.status || ''); - if (stRaw) { - const changed = setBadgeCell(statusCell, stRaw); - const isRunning = /running|online|started/.test(stRaw); - updateMigrateButton(tr, stRaw); - updateActionButtons(tr, isRunning); - if (changed) flashDot(nameCell); - } - } - }); - } catch(e){} - }, 30000); - - // fast refresh: active sids - fastTimer = setInterval(async function(){ - try { - const sids = Array.from(activeSids); - if (!sids.length) return; - for (let i=0;i Date: Sat, 18 Oct 2025 23:52:50 +0200 Subject: [PATCH 24/28] refator_comm1 --- static/js/admin.js | 417 +++++++++++++++++++++++++++++++-------------- 1 file changed, 291 insertions(+), 126 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index 666c460..2ae615a 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -1,155 +1,320 @@ import { rowHTML, setRows, safe, showToast, badge } from './helpers.js'; import { api } from './api.js'; -// helpers -const q = (r, s) => (r || document).querySelector(s); -const qq = (r, s) => Array.from((r || document).querySelectorAll(s)); -const low = (x) => String(x ?? '').toLowerCase(); +// --- State --- +const liveSockets = new Map(); +let slowTimer = null; +let fastTimer = null; +const activeSids = new Set(); + +// --- tiny safe helpers --- +function q(root, sel){ return root ? root.querySelector(sel) : null; } +function qq(root, sel){ return root ? Array.from(root.querySelectorAll(sel)) : []; } +function txt(el){ return (el && el.textContent) ? el.textContent : ''; } +function low(x){ return String(x||'').toLowerCase(); } + +function injectOnceCSS() { + if (document.getElementById('vmadmin-live-css')) return; + const style = document.createElement('style'); + style.id = 'vmadmin-live-css'; + style.textContent = ` + .pulse-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#22c55e;margin-left:.5rem;opacity:.25;animation:pulse-vm 1s ease-in-out 4} + @keyframes pulse-vm {0%,100%{opacity:.25;transform:scale(1)}50%{opacity:1;transform:scale(1.25)}} + `; + document.head.appendChild(style); +} + +function flashDot(cell) { + if (!cell) return; + const dot = document.createElement('span'); + dot.className = 'pulse-dot'; + cell.appendChild(dot); + setTimeout(function(){ if (dot && dot.parentNode) dot.parentNode.removeChild(dot); }, 1500); +} + +function setBadgeCell(cell, textOrState) { + if (!cell) return false; + let html = ''; + const s = low(textOrState); + if (/running|online|started/.test(s)) html = badge('running','ok'); + else if (/stopp|shutdown|offline/.test(s)) html = badge('stopped','dark'); + else if (/working|progress|busy/.test(s)) html = badge('working','info'); + else html = badge(textOrState || '—','dark'); + if (cell.innerHTML !== html) { cell.innerHTML = html; return true; } + return false; +} + +function extractNodeNames(ns){ + if (!ns) return []; + if (Array.isArray(ns.nodes)) return Array.from(new Set(ns.nodes.map(n => String(n.name || n.node || n).trim()).filter(Boolean))); + if (Array.isArray(ns)) return Array.from(new Set(ns.map(n => String(n.name || n.node || n).trim()).filter(Boolean))); + return []; +} -// --- dropdown: wybierz pierwszy inny node od bieżącego function rebuildTargetSelect(selectEl, currentNode, nodes) { - if (!selectEl) return; - const cur = String(currentNode || '').trim(); - const all = (nodes || []).map(n => String(n.name || n.node || n).trim()).filter(Boolean); - const others = all.filter(n => n && n !== cur); + if (!selectEl) return []; + const current = String(currentNode || '').trim(); + const all = (nodes || []).map(n => String(n && (n.name || n.node || n)).trim()).filter(Boolean); + const others = all.filter(n => n !== current); selectEl.innerHTML = others.map(n => ``).join(''); - // domyślnie pierwszy sensowny inny node - if (selectEl.options.length > 0) selectEl.selectedIndex = 0; + // default: pick the "second node" if exists, otherwise first available other + var idx = 0; + if (all.length >= 2) { + const preferred = all[1]; + const j = others.indexOf(preferred); + if (j >= 0) idx = j; + } + if (selectEl.options.length > 0) selectEl.selectedIndex = idx; + return others; } -// --- statusy + przyciski -function boolRunning(statusRaw) { return /running|online|started/i.test(statusRaw || ''); } -function setButtonsByStatus(tr, statusRaw) { - const running = boolRunning(statusRaw); - // start tylko gdy nie działa - const bStart = tr.querySelector('.act-start'); - const bStop = tr.querySelector('.act-stop'); - const bShutdown = tr.querySelector('.act-shutdown'); - - if (bStart) bStart.disabled = running; - if (bStop) bStop.disabled = !running; - if (bShutdown) bShutdown.disabled = !running; - - // MIGRATE: aktywny także dla offline (offline migrate) - const bMig = tr.querySelector('.act-migrate'); - const sel = tr.querySelector('.target-node'); - const hasTarget = !!(sel && sel.value); - // blokujemy tylko gdy nie ma targetu albo trwa „working/progress” - const busy = /working|progress|busy/i.test(statusRaw || ''); - if (bMig) bMig.disabled = !hasTarget || busy; +function updateMigrateButton(tr, isRunning) { + const btn = q(tr, '.act-migrate'); + const targetSel = q(tr, '.target-node'); + const hasTarget = !!(targetSel && targetSel.options && targetSel.options.length > 0); + const enable = !!(isRunning && hasTarget); + if (!btn) return; + if (enable) { btn.removeAttribute('disabled'); btn.classList.remove('disabled'); } + else { btn.setAttribute('disabled',''); btn.classList.add('disabled'); } } -// --- render -export async function renderVMAdmin() { - const wrap = document.getElementById('vm-admin'); - if (!wrap) return; - const tbody = wrap.querySelector('tbody'); +function updateActionButtons(tr, isRunning) { + const bStart = q(tr, '.act-start'); + const bStop = q(tr, '.act-stop'); + const bShutdown = q(tr, '.act-shutdown'); + if (bStart) { if (isRunning) { bStart.setAttribute('disabled',''); bStart.classList.add('disabled'); } else { bStart.removeAttribute('disabled'); bStart.classList.remove('disabled'); } } + if (bStop) { if (isRunning) { bStop.removeAttribute('disabled'); bStop.classList.remove('disabled'); } else { bStop.setAttribute('disabled',''); bStop.classList.add('disabled'); } } + if (bShutdown) { if (isRunning) { bShutdown.removeAttribute('disabled'); bShutdown.classList.remove('disabled'); } else { bShutdown.setAttribute('disabled',''); bShutdown.classList.add('disabled'); } } +} + +export function stopAllAdminWatches() { + liveSockets.forEach(function(ws){ try { ws.close(); } catch(e){} }); + liveSockets.clear(); + if (slowTimer) clearInterval(slowTimer); + if (fastTimer) clearInterval(fastTimer); + slowTimer = null; fastTimer = null; activeSids.clear(); +} + +function ensureWatchOn() { + const tbody = document.querySelector('#vm-admin tbody'); + if (!tbody) return; + qq(tbody, 'tr[data-sid]').forEach(function(tr){ + const sid = tr.getAttribute('data-sid'); + if (!sid || liveSockets.has(sid)) return; + try { + const wsProto = (location.protocol === 'https:') ? 'wss' : 'ws'; + const ws = new WebSocket(wsProto + '://' + location.host + '/ws/observe?sid=' + encodeURIComponent(sid)); + liveSockets.set(sid, ws); + ws.onopen = function(){}; + ws.onclose = function(){ liveSockets.delete(sid); }; + ws.onerror = function(){}; + ws.onmessage = function(ev){ + try { + const msg = JSON.parse(ev.data || '{}'); + if (!msg || !msg.type) return; + const tr2 = tbody.querySelector('tr[data-sid="' + sid + '"]'); + if (!tr2) return; + const statusCell = tr2.children[4]; + const nameCell = tr2.children[2]; + const nodeCell = tr2.children[3]; + const targetSel = q(tr2, '.target-node'); + + if (msg.type === 'status') { + const stRaw = low(msg.status); + const changed = setBadgeCell(statusCell, stRaw); + const isRunning = /running|online|started/.test(stRaw); + updateMigrateButton(tr2, isRunning); + updateActionButtons(tr2, isRunning); + if (changed) flashDot(nameCell); + if (stRaw && /running|stopped|shutdown/.test(stRaw)) { + setTimeout(function(){ activeSids.delete(sid); }, 3000); + } + } + + if (msg.type === 'node' && msg.node) { + const newNode = String(msg.node).trim(); + if (nodeCell && newNode && txt(nodeCell).trim() !== newNode) { + nodeCell.textContent = newNode; + rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); + flashDot(nameCell); + } + } + } catch(e){} + }; + } catch(e){} + }); +} + +export async function startAdminWatches() { + injectOnceCSS(); + const tbody = document.querySelector('#vm-admin tbody'); if (!tbody) return; - // pobierz listy - const [list, nodesSummary] = await Promise.all([api.listAllVmct(), api.nodesSummary()]); - const all = Array.isArray(list.all) ? list.all : []; - const nodes = Array.isArray(nodesSummary?.nodes) ? nodesSummary.nodes : []; + const ns = await api.nodesSummary(); + const availableNodes = extractNodeNames(ns); + setRows(tbody, []); - // zbuduj wiersze (bez checkboxa) - const rows = all.map(vm => { - const status = safe(vm.status || vm.current?.status || vm.current?.qmpstatus || '—'); - const isRun = boolRunning(status); - return rowHTML([ - safe(vm.sid), - safe(vm.name || vm.vmid || vm.sid), - safe(vm.node || '—'), - badge(status, isRun ? 'ok' : 'dark'), + try { + const latest = await api.listAllVmct(); + const all = Array.isArray(latest.all) ? latest.all : []; + const nodesNow = Array.isArray(latest.nodes) ? latest.nodes : []; + window.__nodesCache = nodesNow.slice(); + + const rows = all.map(vm => ({ + sid: safe(vm.sid), + name: safe(vm.name || vm.vmid || vm.sid), + node: safe(vm.node || '—'), + status: safe(vm.status || '—') + })); + + const htmlRows = rows.map(r => rowHTML([ + ``, + safe(r.sid), + safe(r.name), + safe(r.node), + badge(safe(r.status), /running|online|started/i.test(r.status) ? 'ok' : 'dark'), `
`, - `
- - -
` - ], `data-sid="${safe(vm.sid)}"`); - }); + ``, + `` + ])); - setRows(tbody, rows); + setRows(tbody, htmlRows); - // uzupełnij selecty + stany przycisków - qq(tbody, 'tr[data-sid]').forEach(tr => { - const nodeCell = tr.children[2]; - const statusCell= tr.children[3]; - const sel = tr.querySelector('.target-node'); - rebuildTargetSelect(sel, nodeCell?.textContent?.trim(), nodes); - setButtonsByStatus(tr, statusCell?.innerText || ''); - }); + // prepare rows + qq(tbody, 'tr[data-sid]').forEach(function(tr){ + const nodeCell = tr.children[3]; + const targetSel = q(tr, '.target-node'); + const currentNode = txt(nodeCell).trim(); + rebuildTargetSelect(targetSel, currentNode, availableNodes); + updateMigrateButton(tr, /running|online|started/i.test(tr.children[4].innerText)); + }); - // --- delegacja CLICK globalnie: łapie też elementy po rerenderach - document.addEventListener('click', async (ev) => { - const btn = ev.target.closest?.('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate'); - if (!btn) return; - // zapobiegamy submitom, bąbelkowaniu itp. - ev.preventDefault(); - ev.stopPropagation(); - - const tr = btn.closest('tr[data-sid]'); - if (!tr) return; - const sid = tr.getAttribute('data-sid'); - const name = tr.children[1]?.textContent?.trim() || sid; - - // mapowanie akcji - let action = ''; - let target = undefined; - if (btn.classList.contains('act-start')) action = 'start'; - if (btn.classList.contains('act-stop')) action = 'stop'; - if (btn.classList.contains('act-shutdown')) action = 'shutdown'; - if (btn.classList.contains('act-unlock')) action = 'unlock'; - if (btn.classList.contains('act-migrate')) { - action = 'migrate'; - target = tr.querySelector('.target-node')?.value; - if (!target) { - showToast('Migrate', 'Wybierz docelowy node inny niż bieżący', 'warning'); - return; + // delegated events (reliable after refreshes) + tbody.addEventListener('click', async function(ev){ + const t = ev.target; + const btn = t && t.closest ? t.closest('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate') : null; + if (!btn) return; + const tr = btn.closest ? btn.closest('tr[data-sid]') : null; + if (!tr) return; + const sid = tr.getAttribute('data-sid'); + const nameCell = tr.children[2]; + const targetSel = q(tr, '.target-node'); + async function doAction(kind, needsTarget) { + try { + const targetNode = needsTarget ? (targetSel ? targetSel.value : undefined) : undefined; + activeSids.add(sid); + setBadgeCell(tr.children[4], 'working'); + updateMigrateButton(tr, false); + const res = await api.vmAction(sid, kind, targetNode); + if (res && res.ok) { showToast(`Task ${kind} started for ${safe(txt(nameCell))}`); } + else { showToast(`Task ${kind} failed for ${safe(txt(nameCell))}`, 'danger'); } + } catch(e) { showToast(`Error: ${e && e.message ? e.message : e}`, 'danger'); } } - } + if (btn.classList.contains('act-start')) return doAction('start'); + if (btn.classList.contains('act-stop')) return doAction('stop'); + if (btn.classList.contains('act-shutdown')) return doAction('shutdown'); + if (btn.classList.contains('act-unlock')) return doAction('unlock'); + if (btn.classList.contains('act-migrate')) return doAction('migrate', true); + }); - try { - const res = await api.vmAction(sid, action, target); - if (res?.ok) { - showToast('OK', `${action.toUpperCase()} × ${name}`, 'success'); - } else { - showToast('Błąd', res?.error || `Nie udało się: ${action}`, 'danger'); - } - } catch (e) { - showToast('Błąd', String(e?.message || e), 'danger'); - } - }, { capture: true }); // capture: pewność, że nasz handler zadziała przed innymi + window.__nodesCache = availableNodes.slice(); - // prosty, lekki refresh co 30s (statusy i node) - setInterval(async () => { - try { - const latest = await api.listAllVmct(); - const bySid = new Map((latest?.all || []).map(x => [String(x.sid), x])); - qq(tbody, 'tr[data-sid]').forEach(tr => { - const sid = tr.getAttribute('data-sid'); - const row = bySid.get(sid); - if (!row) return; - const nodeCell = tr.children[2]; - const statusCell = tr.children[3]; - const sel = tr.querySelector('.target-node'); + // slow full refresh + slowTimer = setInterval(async function(){ + try { + const latest2 = await api.listAllVmct(); + const all2 = Array.isArray(latest2.all) ? latest2.all : []; + const bySid = new Map(all2.map(x => [String(x.sid), x])); + const nodesNow2 = Array.isArray(latest2.nodes) ? latest2.nodes : (window.__nodesCache || []); + window.__nodesCache = nodesNow2; - const newNode = String(row.node || '').trim(); - if (newNode && nodeCell?.textContent?.trim() !== newNode) { - nodeCell.textContent = newNode; - rebuildTargetSelect(sel, newNode, nodes); + qq(tbody, 'tr[data-sid]').forEach(function(tr){ + const sid = tr.getAttribute('data-sid'); + const rowData = bySid.get(sid); + if (!rowData) return; + const nodeCell = tr.children[3]; + const statusCell= tr.children[4]; + const nameCell = tr.children[2]; + const targetSel = q(tr, '.target-node'); + + const newNode = String(rowData.node || '').trim(); + if (nodeCell && newNode && txt(nodeCell).trim() !== newNode) { + nodeCell.textContent = newNode; + rebuildTargetSelect(targetSel, newNode, nodesNow2); + flashDot(nameCell); + } + + const currentTxt = low((statusCell && statusCell.innerText) || ''); + if (!/working/.test(currentTxt)) { + const stRaw = low(rowData.status || ''); + if (stRaw) { + const changed = setBadgeCell(statusCell, stRaw); + const isRunning = /running|online|started/.test(stRaw); + updateMigrateButton(tr, isRunning); + updateActionButtons(tr, isRunning); + if (changed) flashDot(nameCell); + } + } + }); + } catch(e){} + }, 30000); + + // fast refresh: active sids + fastTimer = setInterval(async function(){ + try { + const sids = Array.from(activeSids); + if (!sids.length) return; + for (let i=0;i Date: Sat, 18 Oct 2025 23:54:54 +0200 Subject: [PATCH 25/28] refator_comm1 --- static/js/admin.js | 417 ++++++++++++++------------------------------- 1 file changed, 126 insertions(+), 291 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index 2ae615a..666c460 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -1,320 +1,155 @@ import { rowHTML, setRows, safe, showToast, badge } from './helpers.js'; import { api } from './api.js'; -// --- State --- -const liveSockets = new Map(); -let slowTimer = null; -let fastTimer = null; -const activeSids = new Set(); - -// --- tiny safe helpers --- -function q(root, sel){ return root ? root.querySelector(sel) : null; } -function qq(root, sel){ return root ? Array.from(root.querySelectorAll(sel)) : []; } -function txt(el){ return (el && el.textContent) ? el.textContent : ''; } -function low(x){ return String(x||'').toLowerCase(); } - -function injectOnceCSS() { - if (document.getElementById('vmadmin-live-css')) return; - const style = document.createElement('style'); - style.id = 'vmadmin-live-css'; - style.textContent = ` - .pulse-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#22c55e;margin-left:.5rem;opacity:.25;animation:pulse-vm 1s ease-in-out 4} - @keyframes pulse-vm {0%,100%{opacity:.25;transform:scale(1)}50%{opacity:1;transform:scale(1.25)}} - `; - document.head.appendChild(style); -} - -function flashDot(cell) { - if (!cell) return; - const dot = document.createElement('span'); - dot.className = 'pulse-dot'; - cell.appendChild(dot); - setTimeout(function(){ if (dot && dot.parentNode) dot.parentNode.removeChild(dot); }, 1500); -} - -function setBadgeCell(cell, textOrState) { - if (!cell) return false; - let html = ''; - const s = low(textOrState); - if (/running|online|started/.test(s)) html = badge('running','ok'); - else if (/stopp|shutdown|offline/.test(s)) html = badge('stopped','dark'); - else if (/working|progress|busy/.test(s)) html = badge('working','info'); - else html = badge(textOrState || '—','dark'); - if (cell.innerHTML !== html) { cell.innerHTML = html; return true; } - return false; -} - -function extractNodeNames(ns){ - if (!ns) return []; - if (Array.isArray(ns.nodes)) return Array.from(new Set(ns.nodes.map(n => String(n.name || n.node || n).trim()).filter(Boolean))); - if (Array.isArray(ns)) return Array.from(new Set(ns.map(n => String(n.name || n.node || n).trim()).filter(Boolean))); - return []; -} +// helpers +const q = (r, s) => (r || document).querySelector(s); +const qq = (r, s) => Array.from((r || document).querySelectorAll(s)); +const low = (x) => String(x ?? '').toLowerCase(); +// --- dropdown: wybierz pierwszy inny node od bieżącego function rebuildTargetSelect(selectEl, currentNode, nodes) { - if (!selectEl) return []; - const current = String(currentNode || '').trim(); - const all = (nodes || []).map(n => String(n && (n.name || n.node || n)).trim()).filter(Boolean); - const others = all.filter(n => n !== current); + if (!selectEl) return; + const cur = String(currentNode || '').trim(); + const all = (nodes || []).map(n => String(n.name || n.node || n).trim()).filter(Boolean); + const others = all.filter(n => n && n !== cur); selectEl.innerHTML = others.map(n => ``).join(''); - // default: pick the "second node" if exists, otherwise first available other - var idx = 0; - if (all.length >= 2) { - const preferred = all[1]; - const j = others.indexOf(preferred); - if (j >= 0) idx = j; - } - if (selectEl.options.length > 0) selectEl.selectedIndex = idx; - return others; + // domyślnie pierwszy sensowny inny node + if (selectEl.options.length > 0) selectEl.selectedIndex = 0; } -function updateMigrateButton(tr, isRunning) { - const btn = q(tr, '.act-migrate'); - const targetSel = q(tr, '.target-node'); - const hasTarget = !!(targetSel && targetSel.options && targetSel.options.length > 0); - const enable = !!(isRunning && hasTarget); - if (!btn) return; - if (enable) { btn.removeAttribute('disabled'); btn.classList.remove('disabled'); } - else { btn.setAttribute('disabled',''); btn.classList.add('disabled'); } +// --- statusy + przyciski +function boolRunning(statusRaw) { return /running|online|started/i.test(statusRaw || ''); } +function setButtonsByStatus(tr, statusRaw) { + const running = boolRunning(statusRaw); + // start tylko gdy nie działa + const bStart = tr.querySelector('.act-start'); + const bStop = tr.querySelector('.act-stop'); + const bShutdown = tr.querySelector('.act-shutdown'); + + if (bStart) bStart.disabled = running; + if (bStop) bStop.disabled = !running; + if (bShutdown) bShutdown.disabled = !running; + + // MIGRATE: aktywny także dla offline (offline migrate) + const bMig = tr.querySelector('.act-migrate'); + const sel = tr.querySelector('.target-node'); + const hasTarget = !!(sel && sel.value); + // blokujemy tylko gdy nie ma targetu albo trwa „working/progress” + const busy = /working|progress|busy/i.test(statusRaw || ''); + if (bMig) bMig.disabled = !hasTarget || busy; } -function updateActionButtons(tr, isRunning) { - const bStart = q(tr, '.act-start'); - const bStop = q(tr, '.act-stop'); - const bShutdown = q(tr, '.act-shutdown'); - if (bStart) { if (isRunning) { bStart.setAttribute('disabled',''); bStart.classList.add('disabled'); } else { bStart.removeAttribute('disabled'); bStart.classList.remove('disabled'); } } - if (bStop) { if (isRunning) { bStop.removeAttribute('disabled'); bStop.classList.remove('disabled'); } else { bStop.setAttribute('disabled',''); bStop.classList.add('disabled'); } } - if (bShutdown) { if (isRunning) { bShutdown.removeAttribute('disabled'); bShutdown.classList.remove('disabled'); } else { bShutdown.setAttribute('disabled',''); bShutdown.classList.add('disabled'); } } -} - -export function stopAllAdminWatches() { - liveSockets.forEach(function(ws){ try { ws.close(); } catch(e){} }); - liveSockets.clear(); - if (slowTimer) clearInterval(slowTimer); - if (fastTimer) clearInterval(fastTimer); - slowTimer = null; fastTimer = null; activeSids.clear(); -} - -function ensureWatchOn() { - const tbody = document.querySelector('#vm-admin tbody'); - if (!tbody) return; - qq(tbody, 'tr[data-sid]').forEach(function(tr){ - const sid = tr.getAttribute('data-sid'); - if (!sid || liveSockets.has(sid)) return; - try { - const wsProto = (location.protocol === 'https:') ? 'wss' : 'ws'; - const ws = new WebSocket(wsProto + '://' + location.host + '/ws/observe?sid=' + encodeURIComponent(sid)); - liveSockets.set(sid, ws); - ws.onopen = function(){}; - ws.onclose = function(){ liveSockets.delete(sid); }; - ws.onerror = function(){}; - ws.onmessage = function(ev){ - try { - const msg = JSON.parse(ev.data || '{}'); - if (!msg || !msg.type) return; - const tr2 = tbody.querySelector('tr[data-sid="' + sid + '"]'); - if (!tr2) return; - const statusCell = tr2.children[4]; - const nameCell = tr2.children[2]; - const nodeCell = tr2.children[3]; - const targetSel = q(tr2, '.target-node'); - - if (msg.type === 'status') { - const stRaw = low(msg.status); - const changed = setBadgeCell(statusCell, stRaw); - const isRunning = /running|online|started/.test(stRaw); - updateMigrateButton(tr2, isRunning); - updateActionButtons(tr2, isRunning); - if (changed) flashDot(nameCell); - if (stRaw && /running|stopped|shutdown/.test(stRaw)) { - setTimeout(function(){ activeSids.delete(sid); }, 3000); - } - } - - if (msg.type === 'node' && msg.node) { - const newNode = String(msg.node).trim(); - if (nodeCell && newNode && txt(nodeCell).trim() !== newNode) { - nodeCell.textContent = newNode; - rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); - flashDot(nameCell); - } - } - } catch(e){} - }; - } catch(e){} - }); -} - -export async function startAdminWatches() { - injectOnceCSS(); - const tbody = document.querySelector('#vm-admin tbody'); +// --- render +export async function renderVMAdmin() { + const wrap = document.getElementById('vm-admin'); + if (!wrap) return; + const tbody = wrap.querySelector('tbody'); if (!tbody) return; - const ns = await api.nodesSummary(); - const availableNodes = extractNodeNames(ns); - setRows(tbody, []); + // pobierz listy + const [list, nodesSummary] = await Promise.all([api.listAllVmct(), api.nodesSummary()]); + const all = Array.isArray(list.all) ? list.all : []; + const nodes = Array.isArray(nodesSummary?.nodes) ? nodesSummary.nodes : []; - try { - const latest = await api.listAllVmct(); - const all = Array.isArray(latest.all) ? latest.all : []; - const nodesNow = Array.isArray(latest.nodes) ? latest.nodes : []; - window.__nodesCache = nodesNow.slice(); - - const rows = all.map(vm => ({ - sid: safe(vm.sid), - name: safe(vm.name || vm.vmid || vm.sid), - node: safe(vm.node || '—'), - status: safe(vm.status || '—') - })); - - const htmlRows = rows.map(r => rowHTML([ - ``, - safe(r.sid), - safe(r.name), - safe(r.node), - badge(safe(r.status), /running|online|started/i.test(r.status) ? 'ok' : 'dark'), + // zbuduj wiersze (bez checkboxa) + const rows = all.map(vm => { + const status = safe(vm.status || vm.current?.status || vm.current?.qmpstatus || '—'); + const isRun = boolRunning(status); + return rowHTML([ + safe(vm.sid), + safe(vm.name || vm.vmid || vm.sid), + safe(vm.node || '—'), + badge(status, isRun ? 'ok' : 'dark'), `
`, - ``, - `` - ])); + `
+ + +
` + ], `data-sid="${safe(vm.sid)}"`); + }); - setRows(tbody, htmlRows); + setRows(tbody, rows); - // prepare rows - qq(tbody, 'tr[data-sid]').forEach(function(tr){ - const nodeCell = tr.children[3]; - const targetSel = q(tr, '.target-node'); - const currentNode = txt(nodeCell).trim(); - rebuildTargetSelect(targetSel, currentNode, availableNodes); - updateMigrateButton(tr, /running|online|started/i.test(tr.children[4].innerText)); - }); + // uzupełnij selecty + stany przycisków + qq(tbody, 'tr[data-sid]').forEach(tr => { + const nodeCell = tr.children[2]; + const statusCell= tr.children[3]; + const sel = tr.querySelector('.target-node'); + rebuildTargetSelect(sel, nodeCell?.textContent?.trim(), nodes); + setButtonsByStatus(tr, statusCell?.innerText || ''); + }); - // delegated events (reliable after refreshes) - tbody.addEventListener('click', async function(ev){ - const t = ev.target; - const btn = t && t.closest ? t.closest('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate') : null; - if (!btn) return; - const tr = btn.closest ? btn.closest('tr[data-sid]') : null; - if (!tr) return; - const sid = tr.getAttribute('data-sid'); - const nameCell = tr.children[2]; - const targetSel = q(tr, '.target-node'); - async function doAction(kind, needsTarget) { - try { - const targetNode = needsTarget ? (targetSel ? targetSel.value : undefined) : undefined; - activeSids.add(sid); - setBadgeCell(tr.children[4], 'working'); - updateMigrateButton(tr, false); - const res = await api.vmAction(sid, kind, targetNode); - if (res && res.ok) { showToast(`Task ${kind} started for ${safe(txt(nameCell))}`); } - else { showToast(`Task ${kind} failed for ${safe(txt(nameCell))}`, 'danger'); } - } catch(e) { showToast(`Error: ${e && e.message ? e.message : e}`, 'danger'); } + // --- delegacja CLICK globalnie: łapie też elementy po rerenderach + document.addEventListener('click', async (ev) => { + const btn = ev.target.closest?.('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate'); + if (!btn) return; + // zapobiegamy submitom, bąbelkowaniu itp. + ev.preventDefault(); + ev.stopPropagation(); + + const tr = btn.closest('tr[data-sid]'); + if (!tr) return; + const sid = tr.getAttribute('data-sid'); + const name = tr.children[1]?.textContent?.trim() || sid; + + // mapowanie akcji + let action = ''; + let target = undefined; + if (btn.classList.contains('act-start')) action = 'start'; + if (btn.classList.contains('act-stop')) action = 'stop'; + if (btn.classList.contains('act-shutdown')) action = 'shutdown'; + if (btn.classList.contains('act-unlock')) action = 'unlock'; + if (btn.classList.contains('act-migrate')) { + action = 'migrate'; + target = tr.querySelector('.target-node')?.value; + if (!target) { + showToast('Migrate', 'Wybierz docelowy node inny niż bieżący', 'warning'); + return; } - if (btn.classList.contains('act-start')) return doAction('start'); - if (btn.classList.contains('act-stop')) return doAction('stop'); - if (btn.classList.contains('act-shutdown')) return doAction('shutdown'); - if (btn.classList.contains('act-unlock')) return doAction('unlock'); - if (btn.classList.contains('act-migrate')) return doAction('migrate', true); - }); + } - window.__nodesCache = availableNodes.slice(); + try { + const res = await api.vmAction(sid, action, target); + if (res?.ok) { + showToast('OK', `${action.toUpperCase()} × ${name}`, 'success'); + } else { + showToast('Błąd', res?.error || `Nie udało się: ${action}`, 'danger'); + } + } catch (e) { + showToast('Błąd', String(e?.message || e), 'danger'); + } + }, { capture: true }); // capture: pewność, że nasz handler zadziała przed innymi - // slow full refresh - slowTimer = setInterval(async function(){ - try { - const latest2 = await api.listAllVmct(); - const all2 = Array.isArray(latest2.all) ? latest2.all : []; - const bySid = new Map(all2.map(x => [String(x.sid), x])); - const nodesNow2 = Array.isArray(latest2.nodes) ? latest2.nodes : (window.__nodesCache || []); - window.__nodesCache = nodesNow2; + // prosty, lekki refresh co 30s (statusy i node) + setInterval(async () => { + try { + const latest = await api.listAllVmct(); + const bySid = new Map((latest?.all || []).map(x => [String(x.sid), x])); + qq(tbody, 'tr[data-sid]').forEach(tr => { + const sid = tr.getAttribute('data-sid'); + const row = bySid.get(sid); + if (!row) return; + const nodeCell = tr.children[2]; + const statusCell = tr.children[3]; + const sel = tr.querySelector('.target-node'); - qq(tbody, 'tr[data-sid]').forEach(function(tr){ - const sid = tr.getAttribute('data-sid'); - const rowData = bySid.get(sid); - if (!rowData) return; - const nodeCell = tr.children[3]; - const statusCell= tr.children[4]; - const nameCell = tr.children[2]; - const targetSel = q(tr, '.target-node'); - - const newNode = String(rowData.node || '').trim(); - if (nodeCell && newNode && txt(nodeCell).trim() !== newNode) { - nodeCell.textContent = newNode; - rebuildTargetSelect(targetSel, newNode, nodesNow2); - flashDot(nameCell); - } - - const currentTxt = low((statusCell && statusCell.innerText) || ''); - if (!/working/.test(currentTxt)) { - const stRaw = low(rowData.status || ''); - if (stRaw) { - const changed = setBadgeCell(statusCell, stRaw); - const isRunning = /running|online|started/.test(stRaw); - updateMigrateButton(tr, isRunning); - updateActionButtons(tr, isRunning); - if (changed) flashDot(nameCell); - } - } - }); - } catch(e){} - }, 30000); - - // fast refresh: active sids - fastTimer = setInterval(async function(){ - try { - const sids = Array.from(activeSids); - if (!sids.length) return; - for (let i=0;i Date: Sat, 18 Oct 2025 23:56:30 +0200 Subject: [PATCH 26/28] refator_comm1 --- static/js/admin.js | 336 ++++++++++++++++++++++++++++++--------------- 1 file changed, 226 insertions(+), 110 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index 666c460..ffc4aa3 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -1,155 +1,271 @@ import { rowHTML, setRows, safe, showToast, badge } from './helpers.js'; import { api } from './api.js'; -// helpers -const q = (r, s) => (r || document).querySelector(s); +// ==== helpers ==== +const q = (r, s) => (r || document).querySelector(s); const qq = (r, s) => Array.from((r || document).querySelectorAll(s)); const low = (x) => String(x ?? '').toLowerCase(); -// --- dropdown: wybierz pierwszy inny node od bieżącego +function isRunning(st) { return /running|online|started/.test(low(st)); } +function isBusy(st) { return /working|progress|busy/.test(low(st)); } + +function setBadge(cell, statusRaw) { + if (!cell) return; + const s = String(statusRaw || '').trim() || '—'; + let hue = 'dark'; + if (isRunning(s)) hue = 'ok'; + else if (isBusy(s)) hue = 'info'; + cell.innerHTML = badge(s, hue); +} + +function extractNodes(nodesSummary) { + if (!nodesSummary) return []; + const arr = Array.isArray(nodesSummary.nodes) ? nodesSummary.nodes : nodesSummary; + return Array.from(new Set((arr || []).map(n => String(n?.name || n?.node || n || '').trim()).filter(Boolean))); +} + function rebuildTargetSelect(selectEl, currentNode, nodes) { if (!selectEl) return; - const cur = String(currentNode || '').trim(); - const all = (nodes || []).map(n => String(n.name || n.node || n).trim()).filter(Boolean); - const others = all.filter(n => n && n !== cur); + const cur = String(currentNode || '').trim(); + const list = (nodes || []).map(n => String(n).trim()).filter(Boolean); + const others = list.filter(n => n && n !== cur); selectEl.innerHTML = others.map(n => ``).join(''); - // domyślnie pierwszy sensowny inny node if (selectEl.options.length > 0) selectEl.selectedIndex = 0; } -// --- statusy + przyciski -function boolRunning(statusRaw) { return /running|online|started/i.test(statusRaw || ''); } -function setButtonsByStatus(tr, statusRaw) { - const running = boolRunning(statusRaw); - // start tylko gdy nie działa +function syncButtonsForRow(tr, statusRaw) { + const running = isRunning(statusRaw); + const busy = isBusy(statusRaw); const bStart = tr.querySelector('.act-start'); const bStop = tr.querySelector('.act-stop'); const bShutdown = tr.querySelector('.act-shutdown'); + const bUnlock = tr.querySelector('.act-unlock'); + const bMigrate = tr.querySelector('.act-migrate'); + const sel = tr.querySelector('.target-node'); - if (bStart) bStart.disabled = running; - if (bStop) bStop.disabled = !running; - if (bShutdown) bShutdown.disabled = !running; + if (bStart) bStart.disabled = running || busy; + if (bStop) bStop.disabled = !running || busy; + if (bShutdown) bShutdown.disabled = !running || busy; + if (bUnlock) bUnlock.disabled = busy; - // MIGRATE: aktywny także dla offline (offline migrate) - const bMig = tr.querySelector('.act-migrate'); - const sel = tr.querySelector('.target-node'); const hasTarget = !!(sel && sel.value); - // blokujemy tylko gdy nie ma targetu albo trwa „working/progress” - const busy = /working|progress|busy/i.test(statusRaw || ''); - if (bMig) bMig.disabled = !hasTarget || busy; + // MIGRATE włączony także dla offline; blokujemy tylko gdy brak targetu albo busy + if (bMigrate) bMigrate.disabled = !hasTarget || busy; } -// --- render +// ==== state: szybki refresh dla aktywnych SID-ów ==== +const activeSids = new Set(); +let slowTimer = null; +let fastTimer = null; +let cachedNodes = []; + +// ==== rendering ==== export async function renderVMAdmin() { - const wrap = document.getElementById('vm-admin'); - if (!wrap) return; - const tbody = wrap.querySelector('tbody'); + const table = document.getElementById('vm-admin'); + if (!table) return; + const tbody = table.querySelector('tbody'); if (!tbody) return; - // pobierz listy + // początkowe pobranie const [list, nodesSummary] = await Promise.all([api.listAllVmct(), api.nodesSummary()]); - const all = Array.isArray(list.all) ? list.all : []; - const nodes = Array.isArray(nodesSummary?.nodes) ? nodesSummary.nodes : []; + const all = Array.isArray(list?.all) ? list.all : []; + cachedNodes = extractNodes(nodesSummary); - // zbuduj wiersze (bez checkboxa) + // zbuduj rzędy 1:1 z nagłówkiem (8 kolumn) const rows = all.map(vm => { + const sid = safe(vm.sid); + const type = safe(vm.type || vm.meta?.type || vm.kind || '—'); + const name = safe(vm.name || vm.vmid || vm.sid); + const node = safe(vm.node || vm.meta?.node || '—'); const status = safe(vm.status || vm.current?.status || vm.current?.qmpstatus || '—'); - const isRun = boolRunning(status); + return rowHTML([ - safe(vm.sid), - safe(vm.name || vm.vmid || vm.sid), - safe(vm.node || '—'), - badge(status, isRun ? 'ok' : 'dark'), + sid, // SID + type, // Type + name, // Name + node, // Node + badge(status, isRunning(status) ? 'ok' : 'dark'), // Status + // Actions `
`, - `
- - -
` - ], `data-sid="${safe(vm.sid)}"`); + // Target + ``, + // Migrate + `` + ], `data-sid="${sid}"`); }); setRows(tbody, rows); - // uzupełnij selecty + stany przycisków + // wstępne wypełnienie selectów i ustawienie stanów przycisków qq(tbody, 'tr[data-sid]').forEach(tr => { - const nodeCell = tr.children[2]; - const statusCell= tr.children[3]; - const sel = tr.querySelector('.target-node'); - rebuildTargetSelect(sel, nodeCell?.textContent?.trim(), nodes); - setButtonsByStatus(tr, statusCell?.innerText || ''); + const nodeCell = tr.children[3]; + const statusCell = tr.children[4]; + const sel = tr.querySelector('.target-node'); + rebuildTargetSelect(sel, nodeCell?.textContent?.trim(), cachedNodes); + syncButtonsForRow(tr, statusCell?.innerText || ''); }); - // --- delegacja CLICK globalnie: łapie też elementy po rerenderach - document.addEventListener('click', async (ev) => { - const btn = ev.target.closest?.('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate'); - if (!btn) return; - // zapobiegamy submitom, bąbelkowaniu itp. - ev.preventDefault(); - ev.stopPropagation(); + // ==== delegacja kliknięć (capture, żeby nic nie przechwyciło) ==== + document.addEventListener('click', onClickAction, { capture: true }); - const tr = btn.closest('tr[data-sid]'); - if (!tr) return; - const sid = tr.getAttribute('data-sid'); - const name = tr.children[1]?.textContent?.trim() || sid; + // ==== odświeżanie ==== + if (slowTimer) clearInterval(slowTimer); + if (fastTimer) clearInterval(fastTimer); - // mapowanie akcji - let action = ''; - let target = undefined; - if (btn.classList.contains('act-start')) action = 'start'; - if (btn.classList.contains('act-stop')) action = 'stop'; - if (btn.classList.contains('act-shutdown')) action = 'shutdown'; - if (btn.classList.contains('act-unlock')) action = 'unlock'; - if (btn.classList.contains('act-migrate')) { - action = 'migrate'; - target = tr.querySelector('.target-node')?.value; - if (!target) { - showToast('Migrate', 'Wybierz docelowy node inny niż bieżący', 'warning'); - return; - } - } + // cały listing co 30s + slowTimer = setInterval(refreshAllRows, 30000); - try { - const res = await api.vmAction(sid, action, target); - if (res?.ok) { - showToast('OK', `${action.toUpperCase()} × ${name}`, 'success'); - } else { - showToast('Błąd', res?.error || `Nie udało się: ${action}`, 'danger'); - } - } catch (e) { - showToast('Błąd', String(e?.message || e), 'danger'); - } - }, { capture: true }); // capture: pewność, że nasz handler zadziała przed innymi - - // prosty, lekki refresh co 30s (statusy i node) - setInterval(async () => { - try { - const latest = await api.listAllVmct(); - const bySid = new Map((latest?.all || []).map(x => [String(x.sid), x])); - qq(tbody, 'tr[data-sid]').forEach(tr => { - const sid = tr.getAttribute('data-sid'); - const row = bySid.get(sid); - if (!row) return; - const nodeCell = tr.children[2]; - const statusCell = tr.children[3]; - const sel = tr.querySelector('.target-node'); - - const newNode = String(row.node || '').trim(); - if (newNode && nodeCell?.textContent?.trim() !== newNode) { - nodeCell.textContent = newNode; - rebuildTargetSelect(sel, newNode, nodes); - } - - const st = String(row.status || row.current?.status || row.current?.qmpstatus || '').trim(); - if (st && statusCell?.innerText?.trim() !== st) { - statusCell.innerHTML = badge(st, boolRunning(st) ? 'ok' : 'dark'); - setButtonsByStatus(tr, st); - } - }); - } catch { /* no-op */ } - }, 30000); + // aktywne SID-y co 10s + fastTimer = setInterval(refreshActiveRows, 10000); +} + +async function onClickAction(ev) { + const btn = ev.target.closest?.('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate'); + if (!btn) return; + ev.preventDefault(); + ev.stopPropagation(); + + const tr = btn.closest('tr[data-sid]'); + if (!tr) return; + const sid = tr.getAttribute('data-sid'); + const name = tr.children[2]?.textContent?.trim() || sid; + const statusCell = tr.children[4]; + const nodeCell = tr.children[3]; + + let action = ''; + let target = undefined; + + if (btn.classList.contains('act-start')) action = 'start'; + if (btn.classList.contains('act-stop')) action = 'stop'; + if (btn.classList.contains('act-shutdown')) action = 'shutdown'; + if (btn.classList.contains('act-unlock')) action = 'unlock'; + if (btn.classList.contains('act-migrate')) { + action = 'migrate'; + const sel = tr.querySelector('.target-node'); + target = sel?.value; + const curNode = nodeCell?.textContent?.trim(); + if (!target || target === curNode) { + showToast('Migrate', 'Wybierz docelowy node inny niż bieżący', 'warning'); + return; + } + } + + try { + // status natychmiast na "working" + blokady + setBadge(statusCell, 'working'); + syncButtonsForRow(tr, 'working'); + activeSids.add(sid); + + const res = await api.vmAction(sid, action, target); + if (res?.ok) { + showToast('OK', `${action.toUpperCase()} × ${name}`, 'success'); + } else { + showToast('Błąd', res?.error || `Nie udało się: ${action}`, 'danger'); + // pozwól wrócić przyciski do sensownych stanów po błędzie + await refreshOneRow(sid, tr); + activeSids.delete(sid); + } + } catch (e) { + showToast('Błąd', String(e?.message || e), 'danger'); + await refreshOneRow(sid, tr); + activeSids.delete(sid); + } +} + +async function refreshAllRows() { + const table = document.getElementById('vm-admin'); + if (!table) return; + const tbody = table.querySelector('tbody'); + if (!tbody) return; + + try { + const latest = await api.listAllVmct(); + const list = Array.isArray(latest?.all) ? latest.all : []; + const bySid = new Map(list.map(x => [String(x.sid), x])); + const nodes = Array.isArray(latest?.nodes) ? latest.nodes : null; + if (nodes) cachedNodes = extractNodes({ nodes }); + + qq(tbody, 'tr[data-sid]').forEach(tr => { + const sid = tr.getAttribute('data-sid'); + const row = bySid.get(sid); + if (!row) return; + + const typeCell = tr.children[1]; + const nameCell = tr.children[2]; + const nodeCell = tr.children[3]; + const statusCell = tr.children[4]; + const sel = tr.querySelector('.target-node'); + + // Node (i przebudowa selecta, jeśli się zmienił) + const newNode = String(row.node || row.meta?.node || '').trim(); + if (newNode && nodeCell?.textContent?.trim() !== newNode) { + nodeCell.textContent = newNode; + rebuildTargetSelect(sel, newNode, cachedNodes); + } + + // Type/Name + const newType = String(row.type || row.meta?.type || row.kind || '—'); + if (typeCell && typeCell.textContent !== newType) typeCell.textContent = newType; + + const newName = String(row.name || row.vmid || row.sid); + if (nameCell && nameCell.textContent !== newName) nameCell.textContent = newName; + + // Status – nie nadpisujemy "working" dla aktywnych SID-ów, + // żeby nie migało w trakcie taska; resztę aktualizujemy. + const st = String(row.status || row.current?.status || row.current?.qmpstatus || '').trim(); + if (!activeSids.has(sid) && st) { + setBadge(statusCell, st); + syncButtonsForRow(tr, st); + } else { + // ale jeśli working „utknął”, to podtrzymujemy blokady + syncButtonsForRow(tr, 'working'); + } + }); + } catch { /* cicho */ } +} + +async function refreshActiveRows() { + if (activeSids.size === 0) return; + const table = document.getElementById('vm-admin'); + if (!table) return; + const tbody = table.querySelector('tbody'); + if (!tbody) return; + + for (const sid of Array.from(activeSids)) { + const tr = tbody.querySelector(`tr[data-sid="${sid}"]`); + if (!tr) { activeSids.delete(sid); continue; } + await refreshOneRow(sid, tr); + } +} + +async function refreshOneRow(sid, tr) { + try { + const detail = await api.vmDetail(sid); + const nodeCell = tr.children[3]; + const statusCell = tr.children[4]; + const sel = tr.querySelector('.target-node'); + + // status z detail + const st = String(detail?.current?.status || detail?.current?.qmpstatus || detail?.status || '').trim(); + if (st) { + setBadge(statusCell, st); + syncButtonsForRow(tr, st); + // jeśli task się skończył, zdejmij z listy aktywnych + if (/running|stopped|shutdown|locked|error|failed|unknown|offline/.test(low(st))) { + activeSids.delete(sid); + } + } + + // node z detail + const newNode = String(detail?.node || detail?.meta?.node || '').trim(); + if (newNode && nodeCell?.textContent?.trim() !== newNode) { + nodeCell.textContent = newNode; + rebuildTargetSelect(sel, newNode, cachedNodes); + } + } catch { /* no-op */ } } From cac9cf927bf2c8babb2ec36737987fda94a086b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 19 Oct 2025 00:00:11 +0200 Subject: [PATCH 27/28] refator_comm1 --- static/js/admin.js | 151 +++++++++++++++++++++++++++------------------ 1 file changed, 90 insertions(+), 61 deletions(-) diff --git a/static/js/admin.js b/static/js/admin.js index ffc4aa3..6b79d18 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -1,13 +1,14 @@ import { rowHTML, setRows, safe, showToast, badge } from './helpers.js'; import { api } from './api.js'; -// ==== helpers ==== +// ========= helpers ========= const q = (r, s) => (r || document).querySelector(s); const qq = (r, s) => Array.from((r || document).querySelectorAll(s)); const low = (x) => String(x ?? '').toLowerCase(); function isRunning(st) { return /running|online|started/.test(low(st)); } -function isBusy(st) { return /working|progress|busy/.test(low(st)); } +function isBusy(st) { return /working|progress|busy|locking|migrating/.test(low(st)); } +function isStopped(st) { return /(stopp|stopped|shutdown|offline|down|halt)/.test(low(st)); } function setBadge(cell, statusRaw) { if (!cell) return; @@ -19,9 +20,8 @@ function setBadge(cell, statusRaw) { } function extractNodes(nodesSummary) { - if (!nodesSummary) return []; - const arr = Array.isArray(nodesSummary.nodes) ? nodesSummary.nodes : nodesSummary; - return Array.from(new Set((arr || []).map(n => String(n?.name || n?.node || n || '').trim()).filter(Boolean))); + const arr = Array.isArray(nodesSummary?.nodes) ? nodesSummary.nodes : (nodesSummary || []); + return Array.from(new Set(arr.map(n => String(n?.name || n?.node || n || '').trim()).filter(Boolean))); } function rebuildTargetSelect(selectEl, currentNode, nodes) { @@ -33,9 +33,19 @@ function rebuildTargetSelect(selectEl, currentNode, nodes) { if (selectEl.options.length > 0) selectEl.selectedIndex = 0; } +// ========= state ========= +const activeSids = new Set(); // SIDs with an ongoing task +const sidWatchdogs = new Map(); // sid -> timeout id (2 min safety) +let slowTimer = null; // 30s for entire table +let fastTimer = null; // 10s for active SIDs +let cachedNodes = []; + +// Buttons reflect current status; MIGRATE only when stopped function syncButtonsForRow(tr, statusRaw) { const running = isRunning(statusRaw); const busy = isBusy(statusRaw); + const stopped = isStopped(statusRaw); + const bStart = tr.querySelector('.act-start'); const bStop = tr.querySelector('.act-stop'); const bShutdown = tr.querySelector('.act-shutdown'); @@ -49,29 +59,22 @@ function syncButtonsForRow(tr, statusRaw) { if (bUnlock) bUnlock.disabled = busy; const hasTarget = !!(sel && sel.value); - // MIGRATE włączony także dla offline; blokujemy tylko gdy brak targetu albo busy - if (bMigrate) bMigrate.disabled = !hasTarget || busy; + // Offline migrate only: enabled only when STOPPED + has target + not busy + if (bMigrate) bMigrate.disabled = !(stopped && hasTarget) || busy; } -// ==== state: szybki refresh dla aktywnych SID-ów ==== -const activeSids = new Set(); -let slowTimer = null; -let fastTimer = null; -let cachedNodes = []; - -// ==== rendering ==== +// ========= rendering ========= export async function renderVMAdmin() { const table = document.getElementById('vm-admin'); if (!table) return; const tbody = table.querySelector('tbody'); if (!tbody) return; - // początkowe pobranie const [list, nodesSummary] = await Promise.all([api.listAllVmct(), api.nodesSummary()]); const all = Array.isArray(list?.all) ? list.all : []; cachedNodes = extractNodes(nodesSummary); - // zbuduj rzędy 1:1 z nagłówkiem (8 kolumn) + // 8 columns exactly like THEAD: SID, Type, Name, Node, Status, Actions, Target, Migrate const rows = all.map(vm => { const sid = safe(vm.sid); const type = safe(vm.type || vm.meta?.type || vm.kind || '—'); @@ -80,28 +83,25 @@ export async function renderVMAdmin() { const status = safe(vm.status || vm.current?.status || vm.current?.qmpstatus || '—'); return rowHTML([ - sid, // SID - type, // Type - name, // Name - node, // Node - badge(status, isRunning(status) ? 'ok' : 'dark'), // Status - // Actions + sid, + type, + name, + node, + badge(status, isRunning(status) ? 'ok' : 'dark'), `
`, - // Target ``, - // Migrate `` ], `data-sid="${sid}"`); }); setRows(tbody, rows); - // wstępne wypełnienie selectów i ustawienie stanów przycisków + // init per-row qq(tbody, 'tr[data-sid]').forEach(tr => { const nodeCell = tr.children[3]; const statusCell = tr.children[4]; @@ -110,20 +110,16 @@ export async function renderVMAdmin() { syncButtonsForRow(tr, statusCell?.innerText || ''); }); - // ==== delegacja kliknięć (capture, żeby nic nie przechwyciło) ==== + // click delegation (capture phase) document.addEventListener('click', onClickAction, { capture: true }); - // ==== odświeżanie ==== - if (slowTimer) clearInterval(slowTimer); - if (fastTimer) clearInterval(fastTimer); - - // cały listing co 30s + // refresh loops + clearInterval(slowTimer); clearInterval(fastTimer); slowTimer = setInterval(refreshAllRows, 30000); - - // aktywne SID-y co 10s fastTimer = setInterval(refreshActiveRows, 10000); } +// ========= actions ========= async function onClickAction(ev) { const btn = ev.target.closest?.('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate'); if (!btn) return; @@ -150,33 +146,54 @@ async function onClickAction(ev) { target = sel?.value; const curNode = nodeCell?.textContent?.trim(); if (!target || target === curNode) { - showToast('Migrate', 'Wybierz docelowy node inny niż bieżący', 'warning'); + showToast('Migrate', 'Pick a target node different from current.', 'warning'); return; } } try { - // status natychmiast na "working" + blokady + // Optimistic: set working, disable buttons, start watchdog setBadge(statusCell, 'working'); syncButtonsForRow(tr, 'working'); activeSids.add(sid); + armWatchdog(sid, name); const res = await api.vmAction(sid, action, target); + + // Most APIs respond before task finishes. We don't show "failed" immediately. + // If API responded at all, we just inform and wait for status polling to flip from "working". if (res?.ok) { - showToast('OK', `${action.toUpperCase()} × ${name}`, 'success'); + showToast('Task queued', `${action.toUpperCase()} scheduled for ${name}.`, 'success'); } else { - showToast('Błąd', res?.error || `Nie udało się: ${action}`, 'danger'); - // pozwól wrócić przyciski do sensownych stanów po błędzie - await refreshOneRow(sid, tr); - activeSids.delete(sid); + // Even if backend returns a non-ok, in practice the task often proceeds. + // We soften the message and keep polling. + showToast('Possibly queued', `Attempted ${action} for ${name}. Waiting for status update…`, 'info'); } } catch (e) { - showToast('Błąd', String(e?.message || e), 'danger'); - await refreshOneRow(sid, tr); - activeSids.delete(sid); + // Network or unexpected error — still keep "working" and wait for polling + showToast('Queued (connection issue)', `Action may have been accepted for ${name}. Monitoring status…`, 'warning'); } } +// 2-minute safety timer per SID: if status didn't change, nudge with info (not hard error) +function armWatchdog(sid, name) { + clearTimeout(sidWatchdogs.get(sid)); + const id = setTimeout(() => { + // Still active after 2 min? Give a gentle notice; keep polling. + if (activeSids.has(sid)) { + showToast('Still processing', `No status change for ${name} yet. Continuing to monitor…`, 'info'); + } + }, 120000); + sidWatchdogs.set(sid, id); +} + +function disarmWatchdog(sid) { + const id = sidWatchdogs.get(sid); + if (id) clearTimeout(id); + sidWatchdogs.delete(sid); +} + +// ========= refresh loops ========= async function refreshAllRows() { const table = document.getElementById('vm-admin'); if (!table) return; @@ -201,32 +218,40 @@ async function refreshAllRows() { const statusCell = tr.children[4]; const sel = tr.querySelector('.target-node'); - // Node (i przebudowa selecta, jeśli się zmienił) const newNode = String(row.node || row.meta?.node || '').trim(); if (newNode && nodeCell?.textContent?.trim() !== newNode) { nodeCell.textContent = newNode; rebuildTargetSelect(sel, newNode, cachedNodes); } - // Type/Name const newType = String(row.type || row.meta?.type || row.kind || '—'); if (typeCell && typeCell.textContent !== newType) typeCell.textContent = newType; const newName = String(row.name || row.vmid || row.sid); if (nameCell && nameCell.textContent !== newName) nameCell.textContent = newName; - // Status – nie nadpisujemy "working" dla aktywnych SID-ów, - // żeby nie migało w trakcie taska; resztę aktualizujemy. const st = String(row.status || row.current?.status || row.current?.qmpstatus || '').trim(); - if (!activeSids.has(sid) && st) { - setBadge(statusCell, st); - syncButtonsForRow(tr, st); - } else { - // ale jeśli working „utknął”, to podtrzymujemy blokady - syncButtonsForRow(tr, 'working'); + if (st) { + // If this SID is in "active", keep working badge until fast loop confirms change + if (!activeSids.has(sid)) { + setBadge(statusCell, st); + syncButtonsForRow(tr, st); + } else { + // Keep buttons locked as working + syncButtonsForRow(tr, 'working'); + } + + // If the status moved to a terminal state, stop the watchdog + if (activeSids.has(sid) && (isRunning(st) || isStopped(st))) { + activeSids.delete(sid); + disarmWatchdog(sid); + setBadge(statusCell, st); + syncButtonsForRow(tr, st); + showToast('Done', `Status updated: ${newName} → ${st}.`, 'success'); + } } }); - } catch { /* cicho */ } + } catch { /* ignore */ } } async function refreshActiveRows() { @@ -238,7 +263,7 @@ async function refreshActiveRows() { for (const sid of Array.from(activeSids)) { const tr = tbody.querySelector(`tr[data-sid="${sid}"]`); - if (!tr) { activeSids.delete(sid); continue; } + if (!tr) { activeSids.delete(sid); disarmWatchdog(sid); continue; } await refreshOneRow(sid, tr); } } @@ -249,23 +274,27 @@ async function refreshOneRow(sid, tr) { const nodeCell = tr.children[3]; const statusCell = tr.children[4]; const sel = tr.querySelector('.target-node'); + const name = tr.children[2]?.textContent?.trim() || sid; - // status z detail const st = String(detail?.current?.status || detail?.current?.qmpstatus || detail?.status || '').trim(); if (st) { + if (isRunning(st) || isStopped(st)) { + activeSids.delete(sid); + disarmWatchdog(sid); + } setBadge(statusCell, st); syncButtonsForRow(tr, st); - // jeśli task się skończył, zdejmij z listy aktywnych - if (/running|stopped|shutdown|locked|error|failed|unknown|offline/.test(low(st))) { - activeSids.delete(sid); + if (!activeSids.has(sid) && (isRunning(st) || isStopped(st))) { + showToast('Done', `Status updated: ${name} → ${st}.`, 'success'); } } - // node z detail const newNode = String(detail?.node || detail?.meta?.node || '').trim(); if (newNode && nodeCell?.textContent?.trim() !== newNode) { nodeCell.textContent = newNode; rebuildTargetSelect(sel, newNode, cachedNodes); } - } catch { /* no-op */ } + } catch { + // keep waiting silently + } } From 3694211fd39e25191e2a7e2dc67b4d8155108c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 19 Oct 2025 00:05:08 +0200 Subject: [PATCH 28/28] refator_comm1 --- static/styles.css | 38 +++++++++++++++++++++++++------------- templates/index.html | 2 +- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/static/styles.css b/static/styles.css index 84850e9..de1107b 100644 --- a/static/styles.css +++ b/static/styles.css @@ -145,7 +145,6 @@ footer.site-footer a:hover { } -/* --- VM Admin: dropdown „target node” ma być zawsze nad wierszem poniżej --- */ #vm-admin, #vm-admin .table-responsive, #vm-admin table, @@ -163,24 +162,37 @@ footer.site-footer a:hover { #vm-admin .target-node { position: relative; z-index: 1001; - /* ponad sąsiednimi wierszami */ } -/* --- Toasty: prawy-dolny róg, stabilnie --- */ +/* Toasts: hard-pinned to bottom-right corner */ #toast-container { - position: fixed; - right: max(env(safe-area-inset-right), 1rem); - bottom: max(env(safe-area-inset-bottom), 1rem); - z-index: 2000; - width: min(480px, 96vw); - max-width: min(480px, 96vw); + position: fixed !important; + right: 0 !important; + bottom: 0 !important; + left: auto !important; + top: auto !important; + + margin: 0 !important; + padding: 1rem !important; + + width: auto; + /* allow toast's own width (e.g., 350px in Bootstrap) */ + max-width: 100vw; + /* safety on tiny screens */ pointer-events: none; + z-index: 3000; } #toast-container .toast { pointer-events: auto; - max-width: 100%; - overflow-wrap: anywhere; - word-break: break-word; - white-space: normal; + margin: 0.25rem 0 0 0; + /* stack vertically */ +} + +@supports (inset: 0) { + + /* If the browser supports logical inset, keep it exact as well */ + #toast-container { + inset: auto 0 0 auto !important; + } } \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 8dd06b4..451ce3a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -279,7 +279,7 @@
-
+