Files
pve-ha-web/static/js/admin.js
Mateusz Gruszczyński a841a1f9ad refator_comm1
2025-10-18 22:45:56 +02:00

269 lines
10 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';
const liveSockets = new Map();
let slowTimer = null;
let fastTimer = null;
const activeSids = new Set();
function injectOnceCSS() {
if (document.getElementById('vmadmin-live-css')) return;
const style = document.createElement('style');
style.id = 'vmadmin-live-css';
style.textContent = `
.pulse-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#22c55e;margin-left:.5rem;opacity:.25;animation:pulse-vm 1s ease-in-out 4}
@keyframes pulse-vm {0%,100%{opacity:.25;transform:scale(1)}50%{opacity:1;transform:scale(1.25)}}
`;
document.head.appendChild(style);
}
function closeForSid(sid) {
const entry = liveSockets.get(sid);
if (!entry) return;
try { entry.obs && entry.obs.close(); } catch {}
liveSockets.delete(sid);
}
export function stopAllAdminWatches() {
if (slowTimer) { clearInterval(slowTimer); slowTimer = null; }
if (fastTimer) { clearInterval(fastTimer); fastTimer = null; }
activeSids.clear();
for (const sid of Array.from(liveSockets.keys())) closeForSid(sid);
}
function flashDot(cell) {
if (!cell) return;
const nameWrap = cell.querySelector('.vm-name-wrap') || (() => {
const w = document.createElement('span'); w.className = 'vm-name-wrap';
while (cell.firstChild) w.appendChild(cell.firstChild);
cell.appendChild(w);
return w;
})();
const dot = document.createElement('span');
dot.className = 'pulse-dot';
nameWrap.appendChild(dot);
setTimeout(() => { try { dot.remove(); } catch {} }, 4200);
}
function setBadgeCell(cell, textOrState) {
if (!cell) return;
let html = '';
const s = String(textOrState || '').toLowerCase();
if (/running|online|started/.test(s)) html = badge('running','ok');
else if (/stopp|shutdown|offline/.test(s)) html = badge('stopped','dark');
else if (/working|progress|busy/.test(s)) html = badge('working','info');
else html = badge(textOrState || '—','dark');
if (cell.innerHTML !== html) {
cell.innerHTML = html;
return true; // zmiana
}
return false;
}
function rebuildTargetSelect(selectEl, currentNode, nodes) {
if (!selectEl) return;
const html = nodes.map(n =>
`<option value="${n}" ${n === currentNode ? 'disabled selected' : ''}>${n}</option>`
).join('');
selectEl.innerHTML = html;
const idx = Array.from(selectEl.options).findIndex(o => !o.disabled);
if (idx >= 0) selectEl.selectedIndex = idx;
}
function setMigrateDisabled(tr, disabled) {
const btn = tr.querySelector('.act-migrate');
if (btn) btn.disabled = !!disabled;
}
export async function renderVMAdmin() {
injectOnceCSS();
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 nameCell = `<span class="vm-name-wrap">${name}</span>`;
const statusCell = badge('—','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}</option>`).join('')}
</select>`;
const migrateBtn = `<button class="btn btn-outline-primary btn-sm act-migrate">Migrate (offline)</button>`;
// SID | TYPE | NAME | NODE | STATUS | ACTIONS | TARGET | MIGRATE
return rowHTML([sid, type.toUpperCase(), nameCell, node, statusCell, actions, sel, migrateBtn], `data-sid="${sid}"`);
});
setRows(tbody, rows);
Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => {
const sid = tr.getAttribute('data-sid');
const nodeCell = tr.children[3];
const statusCell= tr.children[4];
const nameCell = tr.children[2];
const targetSel = tr.querySelector('.target-node');
const ensureWatchOn = () => {
const existing = liveSockets.get(sid);
if (existing && existing.obs && existing.obs.readyState <= 1) return;
closeForSid(sid);
const wsObs = new WebSocket(api.wsObserveURL(sid));
liveSockets.set(sid, { obs: wsObs });
wsObs.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === 'vm' && msg.current) {
const st = String(msg.current.status || msg.current.qmpstatus || '').toLowerCase();
const changed = setBadgeCell(statusCell, st);
const isRunning = /running|online|started/.test(st);
setMigrateDisabled(tr, isRunning);
if (changed) flashDot(nameCell);
}
else if (msg.type === 'task-start') {
setBadgeCell(statusCell, 'working');
activeSids.add(sid);
flashDot(nameCell);
}
else if (msg.type === 'task') {
setBadgeCell(statusCell, 'working');
activeSids.add(sid);
}
else if (msg.type === 'moved' && msg.new_node) {
if (nodeCell && nodeCell.textContent.trim() !== msg.new_node) {
nodeCell.textContent = msg.new_node;
rebuildTargetSelect(targetSel, msg.new_node, window.__nodesCache || []);
flashDot(nameCell);
}
activeSids.add(sid);
}
else if (msg.type === 'done') {
// status końcowy dociągnie kolejny pakiet "vm" lub szybki refresh
setTimeout(() => activeSids.delete(sid), 4000);
flashDot(nameCell);
}
} catch {}
};
wsObs.onclose = () => {
const e = liveSockets.get(sid);
if (e && e.obs === wsObs) liveSockets.set(sid, { obs: null });
};
};
const doAction = async (action, withTarget=false) => {
try {
ensureWatchOn();
if (action !== 'unlock') {
setBadgeCell(statusCell, 'working');
activeSids.add(sid);
}
const target = withTarget ? (targetSel?.value || '') : undefined;
const resp = await api.vmAction(sid, action, target);
if (!resp.ok) throw new Error(resp.error || 'unknown');
flashDot(nameCell);
} catch (e) {
showToast('Error', 'ERROR: ' + (e.message || e), 'danger');
activeSids.delete(sid);
}
};
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));
ensureWatchOn();
});
window.__nodesCache = availableNodes.slice();
// pełna lista co 30 s
slowTimer = setInterval(async () => {
try {
const latest = await api.listAllVmct();
const all = Array.isArray(latest.all) ? latest.all : [];
const bySid = new Map(all.map(x => [String(x.sid), x]));
const nodesNow = Array.isArray(latest.nodes) ? latest.nodes : window.__nodesCache || [];
window.__nodesCache = nodesNow;
Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => {
const sid = tr.getAttribute('data-sid');
const rowData = bySid.get(sid);
if (!rowData) return;
const nodeCell = tr.children[3];
const statusCell= tr.children[4];
const nameCell = tr.children[2];
const targetSel = tr.querySelector('.target-node');
const newNode = String(rowData.node || '').trim();
if (nodeCell && newNode && nodeCell.textContent.trim() !== newNode) {
nodeCell.textContent = newNode;
rebuildTargetSelect(targetSel, newNode, nodesNow);
flashDot(nameCell);
}
// status z wolnego reconcile — tylko gdy brak „working”, żeby nie zagłuszać WS
const currentTxt = (statusCell?.innerText || '').toLowerCase();
if (!/working/.test(currentTxt)) {
// brak statusu w liście — zostaw jak jest, dociągnie WS/fastTimer
}
});
} catch {}
}, 30000);
// tylko aktywne co 10 s (dociąga precyzyjny status + node)
fastTimer = setInterval(async () => {
try {
const sids = Array.from(activeSids);
if (!sids.length) return;
for (const sid of sids) {
const detail = await api.vmDetail(sid);
if (!detail || !detail.meta) continue;
const tr = tbody.querySelector(`tr[data-sid="${sid}"]`);
if (!tr) continue;
const nodeCell = tr.children[3];
const statusCell= tr.children[4];
const nameCell = tr.children[2];
const targetSel = tr.querySelector('.target-node');
const stRaw = String((detail.current && (detail.current.status || detail.current.qmpstatus)) || '').toLowerCase();
const changed = setBadgeCell(statusCell, stRaw);
const isRunning = /running|online|started/.test(stRaw);
setMigrateDisabled(tr, isRunning);
if (changed) flashDot(nameCell);
const newNode = String(detail.node || (detail.meta && detail.meta.node) || '').trim();
if (newNode) {
if (nodeCell && nodeCell.textContent.trim() !== newNode) {
nodeCell.textContent = newNode;
rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []);
flashDot(nameCell);
}
}
if (stRaw && /running|stopped|shutdown/.test(stRaw)) {
setTimeout(() => activeSids.delete(sid), 4000);
}
}
} catch {}
}, 10000);
window.addEventListener('beforeunload', stopAllAdminWatches, { once: true });
}