Files
pve-ha-web/static/js/admin.js
Mateusz Gruszczyński a48a10ed35 refator_comm1
2025-10-18 21:47:39 +02:00

275 lines
11 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 { badge, rowHTML, setRows, safe, showToast } from './helpers.js';
import { api } from './api.js';
/** Globalny rejestr gniazd: sid -> { obs: WebSocket|null, task: WebSocket|null } */
const liveSockets = new Map();
/** Pomocniczo: zamknij WS-y dla sid */
function closeForSid(sid) {
const entry = liveSockets.get(sid);
if (!entry) return;
try { entry.obs && entry.obs.close(); } catch {}
try { entry.task && entry.task.close(); } catch {}
liveSockets.delete(sid);
}
/** Exportowany helper do ew. sprzątania z zewnątrz (opcjonalnie) */
export function stopAllAdminWatches() {
for (const sid of liveSockets.keys()) closeForSid(sid);
}
export async function renderVMAdmin() {
// zanim przebudujemy tabelę zamknij WS-y (unikamy „sierotek”)
stopAllAdminWatches();
const data = await api.listAllVmct();
const arr = Array.isArray(data.all) ? data.all : [];
const availableNodes = Array.isArray(data.nodes) ? data.nodes : [];
const tbody = document.querySelector('#vm-admin tbody');
if (!arr.length) { setRows(tbody, [rowHTML(['Brak VM/CT'])]); return; }
const rows = arr.map(x => {
const sid = safe(x.sid), type = safe(x.type), name = safe(x.name), node = safe(x.node);
const st = /running/i.test(x.status || '') ? badge(x.status,'ok') : badge(x.status || '—','dark');
const actions = `
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary act-unlock">Unlock</button>
<button class="btn btn-outline-success act-start">Start</button>
<button class="btn btn-outline-warning act-shutdown">Shutdown</button>
<button class="btn btn-outline-danger act-stop">Stop</button>
</div>`;
const sel = `<select class="form-select form-select-sm target-node" style="min-width:160px">
${availableNodes.map(n => `<option value="${n}" ${n === x.node ? 'disabled selected' : ''}>${n}${n === x.node ? ' (src)' : ''}</option>`).join('')}
</select>`;
const tools = `<div class="d-flex align-items-center gap-2">
<button class="btn btn-outline-primary btn-sm act-migrate">Migrate (offline)</button>
<button class="btn btn-outline-secondary btn-sm act-status">Status</button>
<button class="btn btn-info btn-sm act-watch" title="Live observe">Watching 🔔</button>
</div>`;
return rowHTML([sid, type.toUpperCase(), name, node, st, actions, sel, tools], `data-sid="${sid}"`);
});
setRows(tbody, rows);
Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => {
const sid = tr.getAttribute('data-sid');
const colSpan = tr.children.length;
const nodeCell = tr.children[3]; // kolumna Node
const badgeCell = tr.children[4]; // kolumna Status
const targetSel = tr.querySelector('.target-node');
const watchBtn = tr.querySelector('.act-watch');
// subpanel (log)
let sub = tr.nextElementSibling;
if (!sub || !sub.classList.contains('mig-row')) {
sub = document.createElement('tr'); sub.className = 'mig-row d-none';
const td = document.createElement('td'); td.colSpan = colSpan;
td.innerHTML = '<div class="p-3 border rounded bg-body-tertiary"><div class="fw-semibold mb-1">Task status</div><pre class="small mb-0 mig-log">—</pre></div>';
sub.appendChild(td); tr.parentNode.insertBefore(sub, tr.nextSibling);
}
const logPre = sub.querySelector('.mig-log');
const toggleSub = (show) => sub.classList.toggle('d-none', !show);
const setRowBusy = (busy) => {
const nameCell = tr.children[2];
let spin = nameCell.querySelector('.op-spin');
if (busy && !spin) {
const span = document.createElement('span');
span.className = 'op-spin spinner-border spinner-border-sm align-middle ms-2';
span.setAttribute('role','status'); span.setAttribute('aria-hidden','true');
nameCell.appendChild(span);
}
if (!busy && spin) spin.remove();
};
const rebuildTargetSelect = (currentNode) => {
if (!targetSel) return;
const current = currentNode || (nodeCell?.textContent || '').trim();
const html = availableNodes.map(n =>
`<option value="${n}" ${n === current ? 'disabled selected' : ''}>${n}${n === current ? ' (src)' : ''}</option>`).join('');
targetSel.innerHTML = html;
// ustaw focus na pierwszy dozwolony, jeśli selected jest disabled
const idx = Array.from(targetSel.options).findIndex(o => !o.disabled);
if (idx >= 0) targetSel.selectedIndex = idx;
};
const getTarget = () => targetSel?.value || '';
// ---- WS task tail (na bieżący UPID) ----
const openTaskWS = (upid, node) => {
if (!upid) return;
toggleSub(true);
logPre.textContent = `UPID: ${upid} @ ${node}\n`;
// zamknij ewentualnie poprzednie tail WS dla tej VM
const entry = liveSockets.get(sid) || {};
try { entry.task && entry.task.close(); } catch {}
const wsTask = new WebSocket(api.wsTaskURL(upid, node));
entry.task = wsTask;
liveSockets.set(sid, entry);
wsTask.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === 'log' && msg.line) {
logPre.textContent += msg.line + '\n';
logPre.scrollTop = logPre.scrollHeight;
} else if (msg.type === 'status' && msg.status) {
const ok = String(msg.status.exitstatus||'').toUpperCase() === 'OK';
const s = String(msg.status.status||'').toLowerCase();
if (badgeCell) badgeCell.innerHTML = ok ? badge('running','ok') :
(s === 'stopped' ? badge('stopped','dark') : badge('working','info'));
} else if (msg.type === 'done') {
const ok = !!msg.ok;
if (badgeCell) badgeCell.innerHTML = ok ? badge('running','ok') : badge('error','err');
setRowBusy(false);
setTimeout(() => toggleSub(false), 1500);
try { document.getElementById('btnRefresh').click(); } catch {}
}
} catch {}
};
wsTask.onerror = () => {};
wsTask.onclose = () => {
// nie czyścimy entry.task, żeby móc rozróżnić zamknięcie „nasze” vs. serwera
};
};
// ---- WS observe (domyślnie ON) ----
const ensureWatchOn = () => {
// jeśli już obserwujemy nic nie rób
const existing = liveSockets.get(sid);
if (existing && existing.obs && existing.obs.readyState <= 1) return;
// upewnij się, że stare gniazda zamknięte
closeForSid(sid);
const wsObs = new WebSocket(api.wsObserveURL(sid));
liveSockets.set(sid, { obs: wsObs, task: null });
// wizualnie: włączony
if (watchBtn) {
watchBtn.classList.remove('btn-outline-info');
watchBtn.classList.add('btn-info');
watchBtn.textContent = 'Watching 🔔';
}
wsObs.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === 'vm' && msg.current) {
// live status (działa także dla start/stop/shutdown bez UPID)
const st = String(msg.current.status || msg.current.qmpstatus || '').toLowerCase();
const ok = /running|online|started/.test(st);
if (badgeCell) {
badgeCell.innerHTML = ok ? badge('running','ok') :
(/stopp|shutdown|offline/.test(st) ? badge('stopped','dark') : badge(st||'—','info'));
}
}
else if (msg.type === 'task-start' && msg.upid && msg.node) {
// jeżeli akcja wystartowała spoza panelu lub bez UPID od API — podepnij log
openTaskWS(msg.upid, msg.node);
}
else if (msg.type === 'task' && msg.upid && msg.status) {
const stopped = String(msg.status||'').toLowerCase() === 'stopped';
if (stopped && typeof msg.exitstatus !== 'undefined') {
const ok = String(msg.exitstatus||'').toUpperCase() === 'OK';
if (badgeCell) badgeCell.innerHTML = ok ? badge('running','ok') : badge('error','err');
} else {
if (badgeCell) badgeCell.innerHTML = badge('working','info');
}
}
else if (msg.type === 'moved' && msg.new_node) {
// VM przeniesiona od razu popraw wiersz
if (nodeCell) nodeCell.textContent = msg.new_node;
rebuildTargetSelect(msg.new_node);
try { document.getElementById('btnRefresh').click(); } catch {}
}
else if (msg.type === 'done' && typeof msg.ok === 'boolean') {
if (badgeCell) badgeCell.innerHTML = msg.ok ? badge('running','ok') : badge('error','err');
}
} catch {}
};
wsObs.onclose = () => {
const e = liveSockets.get(sid);
if (e && e.obs === wsObs) {
// oznacz jako wyłączone
liveSockets.set(sid, { obs: null, task: e.task || null });
if (watchBtn) {
watchBtn.classList.remove('btn-info');
watchBtn.classList.add('btn-outline-info');
watchBtn.textContent = 'Watch 🔔';
}
}
};
wsObs.onerror = () => {};
};
const doAction = async (action, withTarget=false) => {
setRowBusy(true);
try {
const target = withTarget ? getTarget() : undefined;
// ZAWSZE live zapewnia status/log nawet jeśli API nie odda UPID
ensureWatchOn();
if (action !== 'unlock') toggleSub(true); // pokaż subpanel logów dla akcji z zadaniami
const resp = await api.vmAction(sid, action, target);
if (!resp.ok) throw new Error(resp.error || 'unknown');
if (!resp.upid) {
// np. unlock albo środowisko nie oddało UPID poczekaj na task-start z observe
logPre.textContent = `Waiting for task… (${action})\n`;
showToast('Info', `${action} zainicjowane`, 'info');
setRowBusy(false); // spinner wyłącz, status/log dojadą z observe
return;
}
// mamy UPID od razu podłącz tail
openTaskWS(resp.upid, resp.source_node);
} catch (e) {
showToast('Error', 'ERROR: ' + (e.message || e), 'danger');
setRowBusy(false);
toggleSub(false);
}
};
// Akcje
tr.querySelector('.act-unlock')?.addEventListener('click', () => doAction('unlock'));
tr.querySelector('.act-start')?.addEventListener('click', () => doAction('start'));
tr.querySelector('.act-stop')?.addEventListener('click', () => doAction('stop'));
tr.querySelector('.act-shutdown')?.addEventListener('click',() => doAction('shutdown'));
tr.querySelector('.act-migrate')?.addEventListener('click', () => doAction('migrate', true));
// Status pokaż/ukryj subpanel (bez WS)
tr.querySelector('.act-status')?.addEventListener('click', () => toggleSub(sub.classList.contains('d-none')));
// Watch 🔔 manualny toggle (domyślnie jest ON)
if (watchBtn) {
watchBtn.addEventListener('click', () => {
const e = liveSockets.get(sid);
if (e && e.obs) {
closeForSid(sid);
watchBtn.classList.remove('btn-info'); watchBtn.classList.add('btn-outline-info');
watchBtn.textContent = 'Watch 🔔';
} else {
ensureWatchOn();
}
});
}
// startowo: LIVE bez klikania
ensureWatchOn();
});
// sprzątanie przy zamknięciu karty
window.addEventListener('beforeunload', stopAllAdminWatches, { once: true });
}