diff --git a/static/js/admin.js b/static/js/admin.js
index 666c460..ffc4aa3 100644
--- a/static/js/admin.js
+++ b/static/js/admin.js
@@ -1,155 +1,271 @@
import { rowHTML, setRows, safe, showToast, badge } from './helpers.js';
import { api } from './api.js';
-// helpers
-const q = (r, s) => (r || document).querySelector(s);
+// ==== 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 isRunning(st) { return /running|online|started/.test(low(st)); }
+function isBusy(st) { return /working|progress|busy/.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) {
+ if (!nodesSummary) return [];
+ 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 all = (nodes || []).map(n => String(n.name || n.node || n).trim()).filter(Boolean);
- const others = all.filter(n => n && n !== cur);
+ 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('');
- // domyślnie pierwszy sensowny inny node
if (selectEl.options.length > 0) selectEl.selectedIndex = 0;
}
-// --- 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
+function syncButtonsForRow(tr, statusRaw) {
+ const running = isRunning(statusRaw);
+ const busy = isBusy(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;
- if (bStop) bStop.disabled = !running;
- if (bShutdown) bShutdown.disabled = !running;
+ if (bStart) bStart.disabled = running || busy;
+ if (bStop) bStop.disabled = !running || busy;
+ if (bShutdown) bShutdown.disabled = !running || busy;
+ if (bUnlock) bUnlock.disabled = busy;
- // 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;
+ // MIGRATE włączony także dla offline; blokujemy tylko gdy brak targetu albo busy
+ if (bMigrate) bMigrate.disabled = !hasTarget || busy;
}
-// --- render
+// ==== state: szybki refresh dla aktywnych SID-ów ====
+const activeSids = new Set();
+let slowTimer = null;
+let fastTimer = null;
+let cachedNodes = [];
+
+// ==== rendering ====
export async function renderVMAdmin() {
- const wrap = document.getElementById('vm-admin');
- if (!wrap) return;
- const tbody = wrap.querySelector('tbody');
+ const table = document.getElementById('vm-admin');
+ if (!table) return;
+ const tbody = table.querySelector('tbody');
if (!tbody) return;
- // pobierz listy
+ // początkowe pobranie
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 : [];
+ const all = Array.isArray(list?.all) ? list.all : [];
+ cachedNodes = extractNodes(nodesSummary);
- // zbuduj wiersze (bez checkboxa)
+ // zbuduj rzędy 1:1 z nagłówkiem (8 kolumn)
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 || '—');
- const isRun = boolRunning(status);
+
return rowHTML([
- safe(vm.sid),
- safe(vm.name || vm.vmid || vm.sid),
- safe(vm.node || '—'),
- badge(status, isRun ? 'ok' : 'dark'),
+ sid, // SID
+ type, // Type
+ name, // Name
+ node, // Node
+ badge(status, isRunning(status) ? 'ok' : 'dark'), // Status
+ // Actions
`
`,
- `
-
-
-
`
- ], `data-sid="${safe(vm.sid)}"`);
+ // Target
+ ``,
+ // Migrate
+ ``
+ ], `data-sid="${sid}"`);
});
setRows(tbody, rows);
- // uzupełnij selecty + stany przycisków
+ // wstępne wypełnienie selectów i ustawienie stanów 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 || '');
+ 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 || '');
});
- // --- 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();
+ // ==== delegacja kliknięć (capture, żeby nic nie przechwyciło) ====
+ document.addEventListener('click', onClickAction, { capture: true });
- const tr = btn.closest('tr[data-sid]');
- if (!tr) return;
- const sid = tr.getAttribute('data-sid');
- const name = tr.children[1]?.textContent?.trim() || sid;
+ // ==== odświeżanie ====
+ if (slowTimer) clearInterval(slowTimer);
+ if (fastTimer) clearInterval(fastTimer);
- // 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;
- }
- }
+ // cały listing co 30s
+ slowTimer = setInterval(refreshAllRows, 30000);
- 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
-
- // 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');
-
- const newNode = String(row.node || '').trim();
- if (newNode && nodeCell?.textContent?.trim() !== newNode) {
- nodeCell.textContent = newNode;
- rebuildTargetSelect(sel, newNode, nodes);
- }
-
- const st = String(row.status || row.current?.status || row.current?.qmpstatus || '').trim();
- if (st && statusCell?.innerText?.trim() !== st) {
- statusCell.innerHTML = badge(st, boolRunning(st) ? 'ok' : 'dark');
- setButtonsByStatus(tr, st);
- }
- });
- } catch { /* no-op */ }
- }, 30000);
+ // aktywne SID-y co 10s
+ fastTimer = setInterval(refreshActiveRows, 10000);
+}
+
+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', 'Wybierz docelowy node inny niż bieżący', 'warning');
+ return;
+ }
+ }
+
+ try {
+ // status natychmiast na "working" + blokady
+ setBadge(statusCell, 'working');
+ syncButtonsForRow(tr, 'working');
+ activeSids.add(sid);
+
+ 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');
+ // pozwól wrócić przyciski do sensownych stanów po błędzie
+ await refreshOneRow(sid, tr);
+ activeSids.delete(sid);
+ }
+ } catch (e) {
+ showToast('Błąd', String(e?.message || e), 'danger');
+ await refreshOneRow(sid, tr);
+ activeSids.delete(sid);
+ }
+}
+
+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');
+
+ // Node (i przebudowa selecta, jeśli się zmienił)
+ const newNode = String(row.node || row.meta?.node || '').trim();
+ if (newNode && nodeCell?.textContent?.trim() !== newNode) {
+ nodeCell.textContent = newNode;
+ rebuildTargetSelect(sel, newNode, cachedNodes);
+ }
+
+ // Type/Name
+ 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;
+
+ // Status – nie nadpisujemy "working" dla aktywnych SID-ów,
+ // żeby nie migało w trakcie taska; resztę aktualizujemy.
+ const st = String(row.status || row.current?.status || row.current?.qmpstatus || '').trim();
+ if (!activeSids.has(sid) && st) {
+ setBadge(statusCell, st);
+ syncButtonsForRow(tr, st);
+ } else {
+ // ale jeśli working „utknął”, to podtrzymujemy blokady
+ syncButtonsForRow(tr, 'working');
+ }
+ });
+ } catch { /* cicho */ }
+}
+
+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); 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');
+
+ // status z detail
+ const st = String(detail?.current?.status || detail?.current?.qmpstatus || detail?.status || '').trim();
+ if (st) {
+ setBadge(statusCell, st);
+ syncButtonsForRow(tr, st);
+ // jeśli task się skończył, zdejmij z listy aktywnych
+ if (/running|stopped|shutdown|locked|error|failed|unknown|offline/.test(low(st))) {
+ activeSids.delete(sid);
+ }
+ }
+
+ // node z detail
+ const newNode = String(detail?.node || detail?.meta?.node || '').trim();
+ if (newNode && nodeCell?.textContent?.trim() !== newNode) {
+ nodeCell.textContent = newNode;
+ rebuildTargetSelect(sel, newNode, cachedNodes);
+ }
+ } catch { /* no-op */ }
}