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.