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 html = nodes.map(n => `` ).join(''); selectEl.innerHTML = html; const idx = Array.from(selectEl.options).findIndex(o => o.disabled); selectEl.selectedIndex = idx >= 0 ? idx : 0; } function setMigrateDisabled(tr, isRunning) { const btn = tr?.querySelector('.act-migrate'); if (!btn) return; if (isRunning) { btn.removeAttribute('disabled'); btn.classList.remove('disabled'); } else { btn.setAttribute('disabled', ''); btn.classList.add('disabled'); } } export function stopAllAdminWatches() { liveSockets.forEach(ws => { try { ws.close(); } catch {} }); 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; Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { const sid = tr.getAttribute('data-sid'); if (!sid) return; if (liveSockets.has(sid)) return; try { const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/observe?sid=${encodeURIComponent(sid)}`); liveSockets.set(sid, ws); ws.onopen = () => {}; ws.onclose = () => { liveSockets.delete(sid); }; ws.onerror = () => {}; ws.onmessage = (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 = tr.querySelector('.target-node'); if (msg.type === 'status') { const stRaw = String(msg.status || '').toLowerCase(); const changed = setBadgeCell(statusCell, stRaw); const isRunning = /running|online|started/.test(stRaw); setMigrateDisabled(tr, isRunning); if (changed) flashDot(nameCell); if (stRaw && /running|stopped|shutdown/.test(stRaw)) { setTimeout(() => activeSids.delete(sid), 3000); } } if (msg.type === 'node' && msg.node) { const newNode = String(msg.node).trim(); if (nodeCell && newNode && nodeCell.textContent.trim() !== newNode) { nodeCell.textContent = newNode; rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); flashDot(nameCell); } } } catch {} }; } catch {} }); } export async function startAdminWatches() { injectOnceCSS(); const tbody = document.querySelector('#vm-admin tbody'); if (!tbody) return; const ns = await api.nodesSummary(); const availableNodes = Array.isArray(ns?.nodes) ? 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 html = 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'), ``, `
` ])).join(''); setRows(tbody, html); // 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'); rebuildTargetSelect(targetSel, nodeCell?.textContent.trim(), availableNodes); 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'); setMigrateDisabled(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-stop')?.addEventListener('click', () => doAction('stop')); tr.querySelector('.act-shutdown')?.addEventListener('click', () => doAction('shutdown')); 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; rebuildTargetSelect(targetSel, newNode, nodesNow); 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); setMigrateDisabled(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); setMigrateDisabled(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); } }