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