diff --git a/static/js/admin.js b/static/js/admin.js index 2ae615a..666c460 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -1,320 +1,155 @@ 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(); - -// --- tiny safe helpers --- -function q(root, sel){ return root ? root.querySelector(sel) : null; } -function qq(root, sel){ return root ? Array.from(root.querySelectorAll(sel)) : []; } -function txt(el){ return (el && el.textContent) ? el.textContent : ''; } -function low(x){ return String(x||'').toLowerCase(); } - -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(function(){ if (dot && dot.parentNode) dot.parentNode.removeChild(dot); }, 1500); -} - -function setBadgeCell(cell, textOrState) { - if (!cell) return false; - let html = ''; - 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; } - return false; -} - -function extractNodeNames(ns){ - if (!ns) return []; - if (Array.isArray(ns.nodes)) return Array.from(new Set(ns.nodes.map(n => String(n.name || n.node || n).trim()).filter(Boolean))); - if (Array.isArray(ns)) return Array.from(new Set(ns.map(n => String(n.name || n.node || n).trim()).filter(Boolean))); - return []; -} +// helpers +const q = (r, s) => (r || document).querySelector(s); +const qq = (r, s) => Array.from((r || document).querySelectorAll(s)); +const low = (x) => String(x ?? '').toLowerCase(); +// --- dropdown: wybierz pierwszy inny node od bieżącego function rebuildTargetSelect(selectEl, currentNode, nodes) { - if (!selectEl) return []; - const current = String(currentNode || '').trim(); - const all = (nodes || []).map(n => String(n && (n.name || n.node || n)).trim()).filter(Boolean); - const others = all.filter(n => n !== current); + if (!selectEl) return; + const cur = String(currentNode || '').trim(); + const all = (nodes || []).map(n => String(n.name || n.node || n).trim()).filter(Boolean); + const others = all.filter(n => n && n !== cur); selectEl.innerHTML = others.map(n => ``).join(''); - // default: pick the "second node" if exists, otherwise first available other - var idx = 0; - if (all.length >= 2) { - const preferred = all[1]; - const j = others.indexOf(preferred); - if (j >= 0) idx = j; - } - if (selectEl.options.length > 0) selectEl.selectedIndex = idx; - return others; + // domyślnie pierwszy sensowny inny node + if (selectEl.options.length > 0) selectEl.selectedIndex = 0; } -function updateMigrateButton(tr, isRunning) { - const btn = q(tr, '.act-migrate'); - const targetSel = q(tr, '.target-node'); - const hasTarget = !!(targetSel && targetSel.options && targetSel.options.length > 0); - const enable = !!(isRunning && hasTarget); - if (!btn) return; - if (enable) { btn.removeAttribute('disabled'); btn.classList.remove('disabled'); } - else { btn.setAttribute('disabled',''); btn.classList.add('disabled'); } +// --- statusy + przyciski +function boolRunning(statusRaw) { return /running|online|started/i.test(statusRaw || ''); } +function setButtonsByStatus(tr, statusRaw) { + const running = boolRunning(statusRaw); + // start tylko gdy nie działa + const bStart = tr.querySelector('.act-start'); + const bStop = tr.querySelector('.act-stop'); + const bShutdown = tr.querySelector('.act-shutdown'); + + if (bStart) bStart.disabled = running; + if (bStop) bStop.disabled = !running; + if (bShutdown) bShutdown.disabled = !running; + + // MIGRATE: aktywny także dla offline (offline migrate) + const bMig = tr.querySelector('.act-migrate'); + const sel = tr.querySelector('.target-node'); + const hasTarget = !!(sel && sel.value); + // blokujemy tylko gdy nie ma targetu albo trwa „working/progress” + const busy = /working|progress|busy/i.test(statusRaw || ''); + if (bMig) bMig.disabled = !hasTarget || busy; } -function updateActionButtons(tr, isRunning) { - const bStart = q(tr, '.act-start'); - const bStop = q(tr, '.act-stop'); - const bShutdown = q(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'); } } -} - -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; - qq(tbody, 'tr[data-sid]').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 tr2 = tbody.querySelector('tr[data-sid="' + sid + '"]'); - if (!tr2) return; - const statusCell = tr2.children[4]; - const nameCell = tr2.children[2]; - const nodeCell = tr2.children[3]; - const targetSel = q(tr2, '.target-node'); - - if (msg.type === 'status') { - const stRaw = low(msg.status); - const changed = setBadgeCell(statusCell, stRaw); - const isRunning = /running|online|started/.test(stRaw); - updateMigrateButton(tr2, isRunning); - updateActionButtons(tr2, 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 && txt(nodeCell).trim() !== newNode) { - nodeCell.textContent = newNode; - rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []); - flashDot(nameCell); - } - } - } catch(e){} - }; - } catch(e){} - }); -} - -export async function startAdminWatches() { - injectOnceCSS(); - const tbody = document.querySelector('#vm-admin tbody'); +// --- render +export async function renderVMAdmin() { + const wrap = document.getElementById('vm-admin'); + if (!wrap) return; + const tbody = wrap.querySelector('tbody'); if (!tbody) return; - const ns = await api.nodesSummary(); - const availableNodes = extractNodeNames(ns); - setRows(tbody, []); + // pobierz listy + const [list, nodesSummary] = await Promise.all([api.listAllVmct(), api.nodesSummary()]); + const all = Array.isArray(list.all) ? list.all : []; + const nodes = Array.isArray(nodesSummary?.nodes) ? nodesSummary.nodes : []; - 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'), + // zbuduj wiersze (bez checkboxa) + const rows = all.map(vm => { + const status = safe(vm.status || vm.current?.status || vm.current?.qmpstatus || '—'); + const isRun = boolRunning(status); + return rowHTML([ + safe(vm.sid), + safe(vm.name || vm.vmid || vm.sid), + safe(vm.node || '—'), + badge(status, isRun ? 'ok' : 'dark'), `
`, - ``, - `` - ])); + `
+ + +
` + ], `data-sid="${safe(vm.sid)}"`); + }); - setRows(tbody, htmlRows); + setRows(tbody, rows); - // prepare rows - qq(tbody, 'tr[data-sid]').forEach(function(tr){ - const nodeCell = tr.children[3]; - const targetSel = q(tr, '.target-node'); - const currentNode = txt(nodeCell).trim(); - rebuildTargetSelect(targetSel, currentNode, availableNodes); - updateMigrateButton(tr, /running|online|started/i.test(tr.children[4].innerText)); - }); + // uzupełnij selecty + stany przycisków + qq(tbody, 'tr[data-sid]').forEach(tr => { + const nodeCell = tr.children[2]; + const statusCell= tr.children[3]; + const sel = tr.querySelector('.target-node'); + rebuildTargetSelect(sel, nodeCell?.textContent?.trim(), nodes); + setButtonsByStatus(tr, statusCell?.innerText || ''); + }); - // delegated events (reliable after refreshes) - tbody.addEventListener('click', async function(ev){ - const t = ev.target; - const btn = t && t.closest ? t.closest('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate') : null; - if (!btn) return; - const tr = btn.closest ? btn.closest('tr[data-sid]') : null; - if (!tr) return; - const sid = tr.getAttribute('data-sid'); - const nameCell = tr.children[2]; - const targetSel = q(tr, '.target-node'); - async function doAction(kind, needsTarget) { - try { - const targetNode = needsTarget ? (targetSel ? targetSel.value : undefined) : undefined; - activeSids.add(sid); - setBadgeCell(tr.children[4], 'working'); - updateMigrateButton(tr, false); - const res = await api.vmAction(sid, kind, targetNode); - if (res && res.ok) { showToast(`Task ${kind} started for ${safe(txt(nameCell))}`); } - else { showToast(`Task ${kind} failed for ${safe(txt(nameCell))}`, 'danger'); } - } catch(e) { showToast(`Error: ${e && e.message ? e.message : e}`, 'danger'); } + // --- delegacja CLICK globalnie: łapie też elementy po rerenderach + document.addEventListener('click', async (ev) => { + const btn = ev.target.closest?.('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate'); + if (!btn) return; + // zapobiegamy submitom, bąbelkowaniu itp. + ev.preventDefault(); + ev.stopPropagation(); + + const tr = btn.closest('tr[data-sid]'); + if (!tr) return; + const sid = tr.getAttribute('data-sid'); + const name = tr.children[1]?.textContent?.trim() || sid; + + // mapowanie akcji + let action = ''; + let target = undefined; + if (btn.classList.contains('act-start')) action = 'start'; + if (btn.classList.contains('act-stop')) action = 'stop'; + if (btn.classList.contains('act-shutdown')) action = 'shutdown'; + if (btn.classList.contains('act-unlock')) action = 'unlock'; + if (btn.classList.contains('act-migrate')) { + action = 'migrate'; + target = tr.querySelector('.target-node')?.value; + if (!target) { + showToast('Migrate', 'Wybierz docelowy node inny niż bieżący', 'warning'); + return; } - if (btn.classList.contains('act-start')) return doAction('start'); - if (btn.classList.contains('act-stop')) return doAction('stop'); - if (btn.classList.contains('act-shutdown')) return doAction('shutdown'); - if (btn.classList.contains('act-unlock')) return doAction('unlock'); - if (btn.classList.contains('act-migrate')) return doAction('migrate', true); - }); + } - window.__nodesCache = availableNodes.slice(); + try { + const res = await api.vmAction(sid, action, target); + if (res?.ok) { + showToast('OK', `${action.toUpperCase()} × ${name}`, 'success'); + } else { + showToast('Błąd', res?.error || `Nie udało się: ${action}`, 'danger'); + } + } catch (e) { + showToast('Błąd', String(e?.message || e), 'danger'); + } + }, { capture: true }); // capture: pewność, że nasz handler zadziała przed innymi - // slow full refresh - slowTimer = setInterval(async function(){ - try { - const latest2 = await api.listAllVmct(); - const all2 = Array.isArray(latest2.all) ? latest2.all : []; - const bySid = new Map(all2.map(x => [String(x.sid), x])); - const nodesNow2 = Array.isArray(latest2.nodes) ? latest2.nodes : (window.__nodesCache || []); - window.__nodesCache = nodesNow2; + // prosty, lekki refresh co 30s (statusy i node) + setInterval(async () => { + try { + const latest = await api.listAllVmct(); + const bySid = new Map((latest?.all || []).map(x => [String(x.sid), x])); + qq(tbody, 'tr[data-sid]').forEach(tr => { + const sid = tr.getAttribute('data-sid'); + const row = bySid.get(sid); + if (!row) return; + const nodeCell = tr.children[2]; + const statusCell = tr.children[3]; + const sel = tr.querySelector('.target-node'); - qq(tbody, '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 = q(tr, '.target-node'); - - const newNode = String(rowData.node || '').trim(); - if (nodeCell && newNode && txt(nodeCell).trim() !== newNode) { - nodeCell.textContent = newNode; - rebuildTargetSelect(targetSel, newNode, nodesNow2); - flashDot(nameCell); - } - - const currentTxt = low((statusCell && statusCell.innerText) || ''); - if (!/working/.test(currentTxt)) { - 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); - if (changed) flashDot(nameCell); - } - } - }); - } catch(e){} - }, 30000); - - // fast refresh: active sids - fastTimer = setInterval(async function(){ - try { - const sids = Array.from(activeSids); - if (!sids.length) return; - for (let i=0;i