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