diff --git a/app.py b/app.py index bdbcf53..1dbd1db 100644 --- a/app.py +++ b/app.py @@ -22,13 +22,13 @@ def run(cmd: List[str], timeout: int = 25) -> subprocess.CompletedProcess: return subprocess.run(cmd, check=False, text=True, capture_output=True, timeout=timeout) def get_text(cmd: List[str]) -> str: - r = run(cmd) + r = run(cmd, timeout=timeout or 25) return r.stdout if r.returncode == 0 else "" def get_json(cmd: List[str]) -> Any: if cmd and cmd[0] == "pvesh" and "--output-format" not in cmd: cmd = cmd + ["--output-format", "json"] - r = run(cmd) + r = run(cmd, timeout=timeout or 25) if r.returncode != 0 or not r.stdout.strip(): return None try: @@ -36,11 +36,11 @@ def get_json(cmd: List[str]) -> Any: except Exception: return None -def post_json(cmd: List[str]) -> Any: +def post_json(cmd: List[str], timeout: Optional[int] = None) -> Any: # force "create" for POST-like agent calls if cmd and cmd[0] == "pvesh" and len(cmd) > 2 and cmd[1] != "create": cmd = ["pvesh", "create"] + cmd[1:] - r = run(cmd) + r = run(cmd, timeout=timeout or 25) if r.returncode != 0 or not r.stdout.strip(): return None try: @@ -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) + r = run(cmd, timeout=timeout or 25) if r.returncode != 0: out.append(f"ERR: {r.stderr.strip()}") @@ -441,7 +441,7 @@ def vm_locked(typ: str, node: str, vmid: int) -> bool: def run_qm_pct(typ: str, vmid: int, subcmd: str) -> subprocess.CompletedProcess: tool = "qm" if typ == "qemu" else "pct" - return run([tool, subcmd, str(vmid)]) + return run([tool, subcmd, str(vmid)], timeout=120) @app.get("/api/list-all-vmct") def api_list_all_vmct(): @@ -487,7 +487,7 @@ def api_vm_action(): 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) + res = post_json(cmd, timeout=120) upid = None if isinstance(res, dict): upid = res.get("data") or res.get("upid") @@ -510,6 +510,30 @@ 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() + 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 + # 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: + next_start = start_i + return jsonify(ok=True, lines=lines or [], next_start=next_start) + if __name__ == "__main__": import argparse p = argparse.ArgumentParser() diff --git a/static/main.js b/static/main.js index 11f102d..0ac7dd7 100644 --- a/static/main.js +++ b/static/main.js @@ -708,17 +708,21 @@ async function renderVMAdmin() { 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 + // 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]; @@ -727,15 +731,17 @@ async function renderVMAdmin() { await pollTask(upid, srcNode, (st) => { const lines = []; for (const k of ['type', 'status', 'pid', 'starttime', 'user', 'node', 'endtime', 'exitstatus']) { - if (st[k] !== undefined) lines.push(`${k}: ${st[k]}`); + 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 badgeCell2 = tr.children[4]; - if (badgeCell2) badgeCell2.innerHTML = ok ? badge('running', 'ok') : badge('migrate error', 'err'); + 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(); }); } else { @@ -747,7 +753,7 @@ async function renderVMAdmin() { log.textContent += (log.textContent ? '\n' : '') + (ok ? 'Migration finished successfully.' : ('Migration failed: ' + (exit || 'unknown error'))); await doRefresh(); } - } catch (e) { alert('ERROR: ' + e.message); } + } catch (e) { showToast('Error', 'ERROR: ' + e.message, 'danger'); } btn.disabled = false; }; }; @@ -798,4 +804,78 @@ function ensureMigRow(mainTr, colSpan) { } 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; + 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); + } } \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 03a5ea2..fb22520 100644 --- a/templates/index.html +++ b/templates/index.html @@ -304,6 +304,11 @@ + +
+
+
+