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 = '';
+ 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 = '';
- 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)}`;
+ }
+
+
};