import { rowHTML, setRows, safe, showToast, badge } from './helpers.js'; import { api } from './api.js'; // ========= 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(); function isRunning(st) { return /running|online|started/.test(low(st)); } function isBusy(st) { return /working|progress|busy|locking|migrating/.test(low(st)); } function isStopped(st) { return /(stopp|stopped|shutdown|offline|down|halt)/.test(low(st)); } function setBadge(cell, statusRaw) { if (!cell) return; const s = String(statusRaw || '').trim() || '—'; let hue = 'dark'; if (isRunning(s)) hue = 'ok'; else if (isBusy(s)) hue = 'info'; cell.innerHTML = badge(s, hue); } function extractNodes(nodesSummary) { const arr = Array.isArray(nodesSummary?.nodes) ? nodesSummary.nodes : (nodesSummary || []); return Array.from(new Set(arr.map(n => String(n?.name || n?.node || n || '').trim()).filter(Boolean))); } function rebuildTargetSelect(selectEl, currentNode, nodes) { if (!selectEl) return; const cur = String(currentNode || '').trim(); const list = (nodes || []).map(n => String(n).trim()).filter(Boolean); const others = list.filter(n => n && n !== cur); selectEl.innerHTML = others.map(n => ``).join(''); if (selectEl.options.length > 0) selectEl.selectedIndex = 0; } // ========= state ========= const activeSids = new Set(); // SIDs with an ongoing task const sidWatchdogs = new Map(); // sid -> timeout id (2 min safety) let slowTimer = null; // 30s for entire table let fastTimer = null; // 10s for active SIDs let cachedNodes = []; // Buttons reflect current status; MIGRATE only when stopped function syncButtonsForRow(tr, statusRaw) { const running = isRunning(statusRaw); const busy = isBusy(statusRaw); const stopped = isStopped(statusRaw); const bStart = tr.querySelector('.act-start'); const bStop = tr.querySelector('.act-stop'); const bShutdown = tr.querySelector('.act-shutdown'); const bUnlock = tr.querySelector('.act-unlock'); const bMigrate = tr.querySelector('.act-migrate'); const sel = tr.querySelector('.target-node'); if (bStart) bStart.disabled = running || busy; if (bStop) bStop.disabled = !running || busy; if (bShutdown) bShutdown.disabled = !running || busy; if (bUnlock) bUnlock.disabled = busy; const hasTarget = !!(sel && sel.value); // Offline migrate only: enabled only when STOPPED + has target + not busy if (bMigrate) bMigrate.disabled = !(stopped && hasTarget) || busy; } // ========= rendering ========= export async function renderVMAdmin() { const table = document.getElementById('vm-admin'); if (!table) return; const tbody = table.querySelector('tbody'); if (!tbody) return; const [list, nodesSummary] = await Promise.all([api.listAllVmct(), api.nodesSummary()]); const all = Array.isArray(list?.all) ? list.all : []; cachedNodes = extractNodes(nodesSummary); // 8 columns exactly like THEAD: SID, Type, Name, Node, Status, Actions, Target, Migrate const rows = all.map(vm => { const sid = safe(vm.sid); const type = safe(vm.type || vm.meta?.type || vm.kind || '—'); const name = safe(vm.name || vm.vmid || vm.sid); const node = safe(vm.node || vm.meta?.node || '—'); const status = safe(vm.status || vm.current?.status || vm.current?.qmpstatus || '—'); return rowHTML([ sid, type, name, node, badge(status, isRunning(status) ? 'ok' : 'dark'), `
`, ``, `` ], `data-sid="${sid}"`); }); setRows(tbody, rows); // init per-row qq(tbody, 'tr[data-sid]').forEach(tr => { const nodeCell = tr.children[3]; const statusCell = tr.children[4]; const sel = tr.querySelector('.target-node'); rebuildTargetSelect(sel, nodeCell?.textContent?.trim(), cachedNodes); syncButtonsForRow(tr, statusCell?.innerText || ''); }); // click delegation (capture phase) document.addEventListener('click', onClickAction, { capture: true }); // refresh loops clearInterval(slowTimer); clearInterval(fastTimer); slowTimer = setInterval(refreshAllRows, 30000); fastTimer = setInterval(refreshActiveRows, 10000); } // ========= actions ========= async function onClickAction(ev) { const btn = ev.target.closest?.('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate'); if (!btn) return; ev.preventDefault(); ev.stopPropagation(); const tr = btn.closest('tr[data-sid]'); if (!tr) return; const sid = tr.getAttribute('data-sid'); const name = tr.children[2]?.textContent?.trim() || sid; const statusCell = tr.children[4]; const nodeCell = tr.children[3]; 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'; const sel = tr.querySelector('.target-node'); target = sel?.value; const curNode = nodeCell?.textContent?.trim(); if (!target || target === curNode) { showToast('Migrate', 'Pick a target node different from current.', 'warning'); return; } } try { // Optimistic: set working, disable buttons, start watchdog setBadge(statusCell, 'working'); syncButtonsForRow(tr, 'working'); activeSids.add(sid); armWatchdog(sid, name); const res = await api.vmAction(sid, action, target); // Most APIs respond before task finishes. We don't show "failed" immediately. // If API responded at all, we just inform and wait for status polling to flip from "working". if (res?.ok) { showToast('Task queued', `${action.toUpperCase()} scheduled for ${name}.`, 'success'); } else { // Even if backend returns a non-ok, in practice the task often proceeds. // We soften the message and keep polling. showToast('Possibly queued', `Attempted ${action} for ${name}. Waiting for status update…`, 'info'); } } catch (e) { // Network or unexpected error — still keep "working" and wait for polling showToast('Queued (connection issue)', `Action may have been accepted for ${name}. Monitoring status…`, 'warning'); } } // 2-minute safety timer per SID: if status didn't change, nudge with info (not hard error) function armWatchdog(sid, name) { clearTimeout(sidWatchdogs.get(sid)); const id = setTimeout(() => { // Still active after 2 min? Give a gentle notice; keep polling. if (activeSids.has(sid)) { showToast('Still processing', `No status change for ${name} yet. Continuing to monitor…`, 'info'); } }, 120000); sidWatchdogs.set(sid, id); } function disarmWatchdog(sid) { const id = sidWatchdogs.get(sid); if (id) clearTimeout(id); sidWatchdogs.delete(sid); } // ========= refresh loops ========= async function refreshAllRows() { const table = document.getElementById('vm-admin'); if (!table) return; const tbody = table.querySelector('tbody'); if (!tbody) return; try { const latest = await api.listAllVmct(); const list = Array.isArray(latest?.all) ? latest.all : []; const bySid = new Map(list.map(x => [String(x.sid), x])); const nodes = Array.isArray(latest?.nodes) ? latest.nodes : null; if (nodes) cachedNodes = extractNodes({ nodes }); qq(tbody, 'tr[data-sid]').forEach(tr => { const sid = tr.getAttribute('data-sid'); const row = bySid.get(sid); if (!row) return; const typeCell = tr.children[1]; const nameCell = tr.children[2]; const nodeCell = tr.children[3]; const statusCell = tr.children[4]; const sel = tr.querySelector('.target-node'); const newNode = String(row.node || row.meta?.node || '').trim(); if (newNode && nodeCell?.textContent?.trim() !== newNode) { nodeCell.textContent = newNode; rebuildTargetSelect(sel, newNode, cachedNodes); } const newType = String(row.type || row.meta?.type || row.kind || '—'); if (typeCell && typeCell.textContent !== newType) typeCell.textContent = newType; const newName = String(row.name || row.vmid || row.sid); if (nameCell && nameCell.textContent !== newName) nameCell.textContent = newName; const st = String(row.status || row.current?.status || row.current?.qmpstatus || '').trim(); if (st) { // If this SID is in "active", keep working badge until fast loop confirms change if (!activeSids.has(sid)) { setBadge(statusCell, st); syncButtonsForRow(tr, st); } else { // Keep buttons locked as working syncButtonsForRow(tr, 'working'); } // If the status moved to a terminal state, stop the watchdog if (activeSids.has(sid) && (isRunning(st) || isStopped(st))) { activeSids.delete(sid); disarmWatchdog(sid); setBadge(statusCell, st); syncButtonsForRow(tr, st); showToast('Done', `Status updated: ${newName} → ${st}.`, 'success'); } } }); } catch { /* ignore */ } } async function refreshActiveRows() { if (activeSids.size === 0) return; const table = document.getElementById('vm-admin'); if (!table) return; const tbody = table.querySelector('tbody'); if (!tbody) return; for (const sid of Array.from(activeSids)) { const tr = tbody.querySelector(`tr[data-sid="${sid}"]`); if (!tr) { activeSids.delete(sid); disarmWatchdog(sid); continue; } await refreshOneRow(sid, tr); } } async function refreshOneRow(sid, tr) { try { const detail = await api.vmDetail(sid); const nodeCell = tr.children[3]; const statusCell = tr.children[4]; const sel = tr.querySelector('.target-node'); const name = tr.children[2]?.textContent?.trim() || sid; const st = String(detail?.current?.status || detail?.current?.qmpstatus || detail?.status || '').trim(); if (st) { if (isRunning(st) || isStopped(st)) { activeSids.delete(sid); disarmWatchdog(sid); } setBadge(statusCell, st); syncButtonsForRow(tr, st); if (!activeSids.has(sid) && (isRunning(st) || isStopped(st))) { showToast('Done', `Status updated: ${name} → ${st}.`, 'success'); } } const newNode = String(detail?.node || detail?.meta?.node || '').trim(); if (newNode && nodeCell?.textContent?.trim() !== newNode) { nodeCell.textContent = newNode; rebuildTargetSelect(sel, newNode, cachedNodes); } } catch { // keep waiting silently } }