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 closeForSid(sid) { const entry = liveSockets.get(sid); if (!entry) return; try { entry.obs && entry.obs.close(); } catch {} liveSockets.delete(sid); } export function stopAllAdminWatches() { if (slowTimer) { clearInterval(slowTimer); slowTimer = null; } if (fastTimer) { clearInterval(fastTimer); fastTimer = null; } activeSids.clear(); for (const sid of Array.from(liveSockets.keys())) closeForSid(sid); } function flashDot(cell) { if (!cell) return; 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 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; // zmiana } 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); if (idx >= 0) selectEl.selectedIndex = idx; } function setMigrateDisabled(tr, disabled) { const btn = tr.querySelector('.act-migrate'); if (btn) btn.disabled = !!disabled; } export async function renderVMAdmin() { injectOnceCSS(); stopAllAdminWatches(); const data = await api.listAllVmct(); const arr = Array.isArray(data.all) ? data.all : []; const availableNodes = Array.isArray(data.nodes) ? data.nodes : []; const tbody = document.querySelector('#vm-admin tbody'); if (!arr.length) { setRows(tbody, [rowHTML(['Brak VM/CT'])]); return; } const rows = arr.map(x => { const sid = safe(x.sid), type = safe(x.type), name = safe(x.name), node = safe(x.node); const nameCell = `${name}`; const statusCell = badge('—','dark'); const actions = `
`; const sel = ``; const migrateBtn = ``; // SID | TYPE | NAME | NODE | STATUS | ACTIONS | TARGET | MIGRATE return rowHTML([sid, type.toUpperCase(), nameCell, node, statusCell, actions, sel, migrateBtn], `data-sid="${sid}"`); }); setRows(tbody, rows); Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => { const sid = tr.getAttribute('data-sid'); const nodeCell = tr.children[3]; const statusCell= tr.children[4]; const nameCell = tr.children[2]; const targetSel = tr.querySelector('.target-node'); 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 }); 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 changed = setBadgeCell(statusCell, st); const isRunning = /running|online|started/.test(st); setMigrateDisabled(tr, isRunning); if (changed) flashDot(nameCell); } else if (msg.type === 'task-start') { setBadgeCell(statusCell, 'working'); activeSids.add(sid); flashDot(nameCell); } else if (msg.type === 'task') { setBadgeCell(statusCell, 'working'); activeSids.add(sid); } else if (msg.type === 'moved' && 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') { // status końcowy dociągnie kolejny pakiet "vm" lub szybki refresh 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 }); }; }; const doAction = async (action, withTarget=false) => { try { ensureWatchOn(); if (action !== 'unlock') { setBadgeCell(statusCell, 'working'); 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'); flashDot(nameCell); } catch (e) { showToast('Error', 'ERROR: ' + (e.message || e), 'danger'); activeSids.delete(sid); } }; tr.querySelector('.act-unlock')?.addEventListener('click', () => doAction('unlock')); 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-migrate')?.addEventListener('click', () => doAction('migrate', true)); ensureWatchOn(); }); window.__nodesCache = availableNodes.slice(); // pełna lista – co 30 s 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 z wolnego reconcile — tylko gdy brak „working”, żeby nie zagłuszać WS const currentTxt = (statusCell?.innerText || '').toLowerCase(); if (!/working/.test(currentTxt)) { // brak statusu w liście — zostaw jak jest, dociągnie WS/fastTimer } }); } catch {} }, 30000); // tylko aktywne – co 10 s (dociąga precyzyjny 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 }); }