refator_comm1

This commit is contained in:
Mateusz Gruszczyński
2025-10-19 00:00:11 +02:00
parent 766aac8069
commit cac9cf927b

View File

@@ -1,13 +1,14 @@
import { rowHTML, setRows, safe, showToast, badge } from './helpers.js'; import { rowHTML, setRows, safe, showToast, badge } from './helpers.js';
import { api } from './api.js'; import { api } from './api.js';
// ==== helpers ==== // ========= helpers =========
const q = (r, s) => (r || document).querySelector(s); const q = (r, s) => (r || document).querySelector(s);
const qq = (r, s) => Array.from((r || document).querySelectorAll(s)); const qq = (r, s) => Array.from((r || document).querySelectorAll(s));
const low = (x) => String(x ?? '').toLowerCase(); const low = (x) => String(x ?? '').toLowerCase();
function isRunning(st) { return /running|online|started/.test(low(st)); } function isRunning(st) { return /running|online|started/.test(low(st)); }
function isBusy(st) { return /working|progress|busy/.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) { function setBadge(cell, statusRaw) {
if (!cell) return; if (!cell) return;
@@ -19,9 +20,8 @@ function setBadge(cell, statusRaw) {
} }
function extractNodes(nodesSummary) { function extractNodes(nodesSummary) {
if (!nodesSummary) return []; const arr = Array.isArray(nodesSummary?.nodes) ? nodesSummary.nodes : (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)));
return Array.from(new Set((arr || []).map(n => String(n?.name || n?.node || n || '').trim()).filter(Boolean)));
} }
function rebuildTargetSelect(selectEl, currentNode, nodes) { function rebuildTargetSelect(selectEl, currentNode, nodes) {
@@ -33,9 +33,19 @@ function rebuildTargetSelect(selectEl, currentNode, nodes) {
if (selectEl.options.length > 0) selectEl.selectedIndex = 0; 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) { function syncButtonsForRow(tr, statusRaw) {
const running = isRunning(statusRaw); const running = isRunning(statusRaw);
const busy = isBusy(statusRaw); const busy = isBusy(statusRaw);
const stopped = isStopped(statusRaw);
const bStart = tr.querySelector('.act-start'); const bStart = tr.querySelector('.act-start');
const bStop = tr.querySelector('.act-stop'); const bStop = tr.querySelector('.act-stop');
const bShutdown = tr.querySelector('.act-shutdown'); const bShutdown = tr.querySelector('.act-shutdown');
@@ -49,29 +59,22 @@ function syncButtonsForRow(tr, statusRaw) {
if (bUnlock) bUnlock.disabled = busy; if (bUnlock) bUnlock.disabled = busy;
const hasTarget = !!(sel && sel.value); const hasTarget = !!(sel && sel.value);
// MIGRATE włączony także dla offline; blokujemy tylko gdy brak targetu albo busy // Offline migrate only: enabled only when STOPPED + has target + not busy
if (bMigrate) bMigrate.disabled = !hasTarget || busy; if (bMigrate) bMigrate.disabled = !(stopped && hasTarget) || busy;
} }
// ==== state: szybki refresh dla aktywnych SID-ów ==== // ========= rendering =========
const activeSids = new Set();
let slowTimer = null;
let fastTimer = null;
let cachedNodes = [];
// ==== rendering ====
export async function renderVMAdmin() { export async function renderVMAdmin() {
const table = document.getElementById('vm-admin'); const table = document.getElementById('vm-admin');
if (!table) return; if (!table) return;
const tbody = table.querySelector('tbody'); const tbody = table.querySelector('tbody');
if (!tbody) return; if (!tbody) return;
// początkowe pobranie
const [list, nodesSummary] = await Promise.all([api.listAllVmct(), api.nodesSummary()]); const [list, nodesSummary] = await Promise.all([api.listAllVmct(), api.nodesSummary()]);
const all = Array.isArray(list?.all) ? list.all : []; const all = Array.isArray(list?.all) ? list.all : [];
cachedNodes = extractNodes(nodesSummary); cachedNodes = extractNodes(nodesSummary);
// zbuduj rzędy 1:1 z nagłówkiem (8 kolumn) // 8 columns exactly like THEAD: SID, Type, Name, Node, Status, Actions, Target, Migrate
const rows = all.map(vm => { const rows = all.map(vm => {
const sid = safe(vm.sid); const sid = safe(vm.sid);
const type = safe(vm.type || vm.meta?.type || vm.kind || '—'); const type = safe(vm.type || vm.meta?.type || vm.kind || '—');
@@ -80,28 +83,25 @@ export async function renderVMAdmin() {
const status = safe(vm.status || vm.current?.status || vm.current?.qmpstatus || '—'); const status = safe(vm.status || vm.current?.status || vm.current?.qmpstatus || '—');
return rowHTML([ return rowHTML([
sid, // SID sid,
type, // Type type,
name, // Name name,
node, // Node node,
badge(status, isRunning(status) ? 'ok' : 'dark'), // Status badge(status, isRunning(status) ? 'ok' : 'dark'),
// Actions
`<div class="btn-group"> `<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-success act-start">Start</button> <button type="button" class="btn btn-sm btn-outline-success act-start">Start</button>
<button type="button" class="btn btn-sm btn-outline-warning act-shutdown">Shutdown</button> <button type="button" class="btn btn-sm btn-outline-warning act-shutdown">Shutdown</button>
<button type="button" class="btn btn-sm btn-outline-danger act-stop">Stop</button> <button type="button" class="btn btn-sm btn-outline-danger act-stop">Stop</button>
<button type="button" class="btn btn-sm btn-outline-secondary act-unlock">Unlock</button> <button type="button" class="btn btn-sm btn-outline-secondary act-unlock">Unlock</button>
</div>`, </div>`,
// Target
`<select class="form-select form-select-sm target-node"></select>`, `<select class="form-select form-select-sm target-node"></select>`,
// Migrate
`<button type="button" class="btn btn-sm btn-outline-primary act-migrate">Migrate</button>` `<button type="button" class="btn btn-sm btn-outline-primary act-migrate">Migrate</button>`
], `data-sid="${sid}"`); ], `data-sid="${sid}"`);
}); });
setRows(tbody, rows); setRows(tbody, rows);
// wstępne wypełnienie selectów i ustawienie stanów przycisków // init per-row
qq(tbody, 'tr[data-sid]').forEach(tr => { qq(tbody, 'tr[data-sid]').forEach(tr => {
const nodeCell = tr.children[3]; const nodeCell = tr.children[3];
const statusCell = tr.children[4]; const statusCell = tr.children[4];
@@ -110,20 +110,16 @@ export async function renderVMAdmin() {
syncButtonsForRow(tr, statusCell?.innerText || ''); syncButtonsForRow(tr, statusCell?.innerText || '');
}); });
// ==== delegacja kliknięć (capture, żeby nic nie przechwyciło) ==== // click delegation (capture phase)
document.addEventListener('click', onClickAction, { capture: true }); document.addEventListener('click', onClickAction, { capture: true });
// ==== odświeżanie ==== // refresh loops
if (slowTimer) clearInterval(slowTimer); clearInterval(slowTimer); clearInterval(fastTimer);
if (fastTimer) clearInterval(fastTimer);
// cały listing co 30s
slowTimer = setInterval(refreshAllRows, 30000); slowTimer = setInterval(refreshAllRows, 30000);
// aktywne SID-y co 10s
fastTimer = setInterval(refreshActiveRows, 10000); fastTimer = setInterval(refreshActiveRows, 10000);
} }
// ========= actions =========
async function onClickAction(ev) { async function onClickAction(ev) {
const btn = ev.target.closest?.('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate'); const btn = ev.target.closest?.('.act-start,.act-stop,.act-shutdown,.act-unlock,.act-migrate');
if (!btn) return; if (!btn) return;
@@ -150,33 +146,54 @@ async function onClickAction(ev) {
target = sel?.value; target = sel?.value;
const curNode = nodeCell?.textContent?.trim(); const curNode = nodeCell?.textContent?.trim();
if (!target || target === curNode) { if (!target || target === curNode) {
showToast('Migrate', 'Wybierz docelowy node inny niż bieżący', 'warning'); showToast('Migrate', 'Pick a target node different from current.', 'warning');
return; return;
} }
} }
try { try {
// status natychmiast na "working" + blokady // Optimistic: set working, disable buttons, start watchdog
setBadge(statusCell, 'working'); setBadge(statusCell, 'working');
syncButtonsForRow(tr, 'working'); syncButtonsForRow(tr, 'working');
activeSids.add(sid); activeSids.add(sid);
armWatchdog(sid, name);
const res = await api.vmAction(sid, action, target); 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) { if (res?.ok) {
showToast('OK', `${action.toUpperCase()} × ${name}`, 'success'); showToast('Task queued', `${action.toUpperCase()} scheduled for ${name}.`, 'success');
} else { } else {
showToast('Błąd', res?.error || `Nie udało się: ${action}`, 'danger'); // Even if backend returns a non-ok, in practice the task often proceeds.
// pozwól wrócić przyciski do sensownych stanów po błędzie // We soften the message and keep polling.
await refreshOneRow(sid, tr); showToast('Possibly queued', `Attempted ${action} for ${name}. Waiting for status update…`, 'info');
activeSids.delete(sid);
} }
} catch (e) { } catch (e) {
showToast('Błąd', String(e?.message || e), 'danger'); // Network or unexpected error — still keep "working" and wait for polling
await refreshOneRow(sid, tr); showToast('Queued (connection issue)', `Action may have been accepted for ${name}. Monitoring status…`, 'warning');
activeSids.delete(sid);
} }
} }
// 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() { async function refreshAllRows() {
const table = document.getElementById('vm-admin'); const table = document.getElementById('vm-admin');
if (!table) return; if (!table) return;
@@ -201,32 +218,40 @@ async function refreshAllRows() {
const statusCell = tr.children[4]; const statusCell = tr.children[4];
const sel = tr.querySelector('.target-node'); const sel = tr.querySelector('.target-node');
// Node (i przebudowa selecta, jeśli się zmienił)
const newNode = String(row.node || row.meta?.node || '').trim(); const newNode = String(row.node || row.meta?.node || '').trim();
if (newNode && nodeCell?.textContent?.trim() !== newNode) { if (newNode && nodeCell?.textContent?.trim() !== newNode) {
nodeCell.textContent = newNode; nodeCell.textContent = newNode;
rebuildTargetSelect(sel, newNode, cachedNodes); rebuildTargetSelect(sel, newNode, cachedNodes);
} }
// Type/Name
const newType = String(row.type || row.meta?.type || row.kind || '—'); const newType = String(row.type || row.meta?.type || row.kind || '—');
if (typeCell && typeCell.textContent !== newType) typeCell.textContent = newType; if (typeCell && typeCell.textContent !== newType) typeCell.textContent = newType;
const newName = String(row.name || row.vmid || row.sid); const newName = String(row.name || row.vmid || row.sid);
if (nameCell && nameCell.textContent !== newName) nameCell.textContent = newName; 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(); const st = String(row.status || row.current?.status || row.current?.qmpstatus || '').trim();
if (!activeSids.has(sid) && st) { if (st) {
// If this SID is in "active", keep working badge until fast loop confirms change
if (!activeSids.has(sid)) {
setBadge(statusCell, st); setBadge(statusCell, st);
syncButtonsForRow(tr, st); syncButtonsForRow(tr, st);
} else { } else {
// ale jeśli working „utknął”, to podtrzymujemy blokady // Keep buttons locked as working
syncButtonsForRow(tr, '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 { /* cicho */ } } catch { /* ignore */ }
} }
async function refreshActiveRows() { async function refreshActiveRows() {
@@ -238,7 +263,7 @@ async function refreshActiveRows() {
for (const sid of Array.from(activeSids)) { for (const sid of Array.from(activeSids)) {
const tr = tbody.querySelector(`tr[data-sid="${sid}"]`); const tr = tbody.querySelector(`tr[data-sid="${sid}"]`);
if (!tr) { activeSids.delete(sid); continue; } if (!tr) { activeSids.delete(sid); disarmWatchdog(sid); continue; }
await refreshOneRow(sid, tr); await refreshOneRow(sid, tr);
} }
} }
@@ -249,23 +274,27 @@ async function refreshOneRow(sid, tr) {
const nodeCell = tr.children[3]; const nodeCell = tr.children[3];
const statusCell = tr.children[4]; const statusCell = tr.children[4];
const sel = tr.querySelector('.target-node'); const sel = tr.querySelector('.target-node');
const name = tr.children[2]?.textContent?.trim() || sid;
// status z detail
const st = String(detail?.current?.status || detail?.current?.qmpstatus || detail?.status || '').trim(); const st = String(detail?.current?.status || detail?.current?.qmpstatus || detail?.status || '').trim();
if (st) { if (st) {
if (isRunning(st) || isStopped(st)) {
activeSids.delete(sid);
disarmWatchdog(sid);
}
setBadge(statusCell, st); setBadge(statusCell, st);
syncButtonsForRow(tr, st); syncButtonsForRow(tr, st);
// jeśli task się skończył, zdejmij z listy aktywnych if (!activeSids.has(sid) && (isRunning(st) || isStopped(st))) {
if (/running|stopped|shutdown|locked|error|failed|unknown|offline/.test(low(st))) { showToast('Done', `Status updated: ${name}${st}.`, 'success');
activeSids.delete(sid);
} }
} }
// node z detail
const newNode = String(detail?.node || detail?.meta?.node || '').trim(); const newNode = String(detail?.node || detail?.meta?.node || '').trim();
if (newNode && nodeCell?.textContent?.trim() !== newNode) { if (newNode && nodeCell?.textContent?.trim() !== newNode) {
nodeCell.textContent = newNode; nodeCell.textContent = newNode;
rebuildTargetSelect(sel, newNode, cachedNodes); rebuildTargetSelect(sel, newNode, cachedNodes);
} }
} catch { /* no-op */ } } catch {
// keep waiting silently
}
} }