Files
pve-ha-web/static/js/admin.js
Mateusz Gruszczyński 766aac8069 refator_comm1
2025-10-18 23:56:30 +02:00

272 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/.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 list = (nodes || []).map(n => String(n).trim()).filter(Boolean);
const others = list.filter(n => n && n !== cur);
selectEl.innerHTML = others.map(n => `<option value="${n}">${n}</option>`).join('');
if (selectEl.options.length > 0) selectEl.selectedIndex = 0;
}
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 || busy;
if (bStop) bStop.disabled = !running || busy;
if (bShutdown) bShutdown.disabled = !running || busy;
if (bUnlock) bUnlock.disabled = busy;
const hasTarget = !!(sel && sel.value);
// MIGRATE włączony także dla offline; blokujemy tylko gdy brak targetu albo busy
if (bMigrate) bMigrate.disabled = !hasTarget || busy;
}
// ==== 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 table = document.getElementById('vm-admin');
if (!table) return;
const tbody = table.querySelector('tbody');
if (!tbody) return;
// początkowe pobranie
const [list, nodesSummary] = await Promise.all([api.listAllVmct(), api.nodesSummary()]);
const all = Array.isArray(list?.all) ? list.all : [];
cachedNodes = extractNodes(nodesSummary);
// 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 || '—');
return rowHTML([
sid, // SID
type, // Type
name, // Name
node, // Node
badge(status, isRunning(status) ? 'ok' : 'dark'), // Status
// Actions
`<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-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-secondary act-unlock">Unlock</button>
</div>`,
// Target
`<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>`
], `data-sid="${sid}"`);
});
setRows(tbody, rows);
// wstępne wypełnienie selectów i ustawienie stanów przycisków
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 || '');
});
// ==== delegacja kliknięć (capture, żeby nic nie przechwyciło) ====
document.addEventListener('click', onClickAction, { capture: true });
// ==== odświeżanie ====
if (slowTimer) clearInterval(slowTimer);
if (fastTimer) clearInterval(fastTimer);
// cały listing co 30s
slowTimer = setInterval(refreshAllRows, 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 */ }
}