import { rowHTML, setRows, safe, showToast, badge } from './helpers.js'; import { api } from './api.js'; const liveSockets = new Map(); 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 flashDot(cell) { if (!cell) return; const dot = document.createElement('span'); dot.className = 'pulse-dot'; cell.appendChild(dot); setTimeout(() => dot.remove(), 1500); } function setBadgeCell(cell, textOrState) { if (!cell) return; let html = ''; const s = String(textOrState || '').toLowerCase(); 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 } return false; } function rebuildTargetSelect(selectEl, currentNode, nodes) { if (!selectEl) return []; const others = (nodes || []) .map(n => String(n).trim()) .filter(Boolean) .filter(n => n !== String(currentNode || '').trim()); selectEl.innerHTML = others.map(n => ``).join(''); if (selectEl.options.length > 0) { selectEl.selectedIndex = 0; } return others; // return list to decide enable/disable of MIGRATE } function updateMigrateButton(tr, isRunning) { const btn = tr?.querySelector('.act-migrate'); const targetSel = tr?.querySelector('.target-node'); 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'); } } 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'); } } } ? 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))) : []); setRows(tbody, []); // initial table fill try { const latest = await api.listAllVmct(); const all = Array.isArray(latest.all) ? latest.all : []; const nodesNow = Array.isArray(latest.nodes) ? latest.nodes : []; window.__nodesCache = nodesNow.slice(); const rows = all.map(vm => ({ sid: safe(vm.sid), name: safe(vm.name || vm.vmid || vm.sid), node: safe(vm.node || '—'), status: safe(vm.status || '—') })); 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 => { const nodeCell = tr.children[3]; const targetSel = tr.querySelector('.target-node'); const _others = rebuildTargetSelect(targetSel, nodeCell?.textContent.trim(), availableNodes); updateMigrateButton(tr, /running|online|started/i.test(tr.children[4].innerText)); const sid = tr.getAttribute('data-sid'); const nameCell = tr.children[2]; async function doAction(kind, needsTarget) { try { const targetNode = needsTarget ? targetSel?.value : 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)}`); } else { showToast(`Task ${kind} failed for ${safe(nameCell.textContent)}`, 'danger'); } } catch (e) { showToast(`Error: ${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)); ensureWatchOn(); }); window.__nodesCache = availableNodes.slice(); // full refresh – every 30s slowTimer = setInterval(async () => { 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 || []; window.__nodesCache = nodesNow; Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(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 newNode = String(rowData.node || '').trim(); if (nodeCell && newNode && nodeCell.textContent.trim() !== newNode) { nodeCell.textContent = newNode; const _others = rebuildTargetSelect(targetSel, newNode, nodesNow); updateMigrateButton(tr, /running|online|started/i.test(tr.children[4].innerText)); flashDot(nameCell); } // status from slow reconcile — only when not 'working' to avoid overruling WS const currentTxt = (statusCell?.innerText || '').toLowerCase(); if (!/working/.test(currentTxt)) { const stRaw = String(rowData.status || '').toLowerCase(); // fallback z /cluster/resources 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 {} }, 30000); // active only – every 10s (pull precise status + node) fastTimer = setInterval(async () => { try { const sids = Array.from(activeSids); if (!sids.length) return; for (const sid of sids) { const detail = await api.vmDetail(sid); if (!detail || !detail.meta) continue; const tr = tbody.querySelector(`tr[data-sid="${sid}"]`); if (!tr) continue; const nodeCell = tr.children[3]; const statusCell= 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 changed = setBadgeCell(statusCell, stRaw); const isRunning = /running|online|started/.test(stRaw); updateMigrateButton(tr, isRunning); updateActionButtons(tr, isRunning); updateActionButtons(tr, isRunning); if (changed) flashDot(nameCell); const newNode = String(detail.node || (detail.meta && detail.meta.node) || '').trim(); if (newNode) { if (nodeCell && nodeCell.textContent.trim() !== newNode) { nodeCell.textContent = newNode; rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); flashDot(nameCell); } } if (stRaw && /running|stopped|shutdown/.test(stRaw)) { setTimeout(() => activeSids.delete(sid), 4000); } } } catch {} }, 10000); window.addEventListener('beforeunload', stopAllAdminWatches, { once: true }); } catch (e) { showToast(`Failed to load list: ${e?.message || e}`, 'danger'); } } // Entry point expected by main.js export async function renderVMAdmin() { try { await startAdminWatches(); } catch (e) { showToast(`VM Admin initialization error: ${e?.message || e}`, 'danger'); console.error(e); } }