Files
pve-ha-web/static/js/admin.js
Mateusz Gruszczyński 3dd0131088 refator_comm1
2025-10-18 23:03:16 +02:00

296 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 { 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 flashDot(cell) {
if (!cell) return;
const dot = document.createElement('span');
dot.className = 'pulse-dot';
cell.appendChild(dot);
setTimeout(() => dot.remove(), 1500);
}
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; // changed
}
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);
selectEl.selectedIndex = idx >= 0 ? idx : 0;
}
function setMigrateDisabled(tr, isRunning) {
const btn = tr?.querySelector('.act-migrate');
if (!btn) return;
if (isRunning) {
btn.removeAttribute('disabled');
btn.classList.remove('disabled');
} else {
btn.setAttribute('disabled', '');
btn.classList.add('disabled');
}
}
export function stopAllAdminWatches() {
liveSockets.forEach(ws => { try { ws.close(); } catch {} });
liveSockets.clear();
if (slowTimer) clearInterval(slowTimer);
if (fastTimer) clearInterval(fastTimer);
slowTimer = null;
fastTimer = null;
activeSids.clear();
}
function ensureWatchOn() {
const tbody = document.querySelector('#vm-admin tbody');
if (!tbody) return;
Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => {
const sid = tr.getAttribute('data-sid');
if (!sid) return;
if (liveSockets.has(sid)) return;
try {
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/observe?sid=${encodeURIComponent(sid)}`);
liveSockets.set(sid, ws);
ws.onopen = () => {};
ws.onclose = () => { liveSockets.delete(sid); };
ws.onerror = () => {};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data || '{}');
if (!msg || !msg.type) return;
const tr = tbody.querySelector(`tr[data-sid="${sid}"]`);
if (!tr) return;
const statusCell = tr.children[4];
const nameCell = tr.children[2];
const nodeCell = tr.children[3];
const targetSel = tr.querySelector('.target-node');
if (msg.type === 'status') {
const stRaw = String(msg.status || '').toLowerCase();
const changed = setBadgeCell(statusCell, stRaw);
const isRunning = /running|online|started/.test(stRaw);
setMigrateDisabled(tr, isRunning);
if (changed) flashDot(nameCell);
if (stRaw && /running|stopped|shutdown/.test(stRaw)) {
setTimeout(() => activeSids.delete(sid), 3000);
}
}
if (msg.type === 'node' && msg.node) {
const newNode = String(msg.node).trim();
if (nodeCell && newNode && nodeCell.textContent.trim() !== newNode) {
nodeCell.textContent = newNode;
rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []);
flashDot(nameCell);
}
}
} catch {}
};
} catch {}
});
}
export async function startAdminWatches() {
injectOnceCSS();
const tbody = document.querySelector('#vm-admin tbody');
if (!tbody) return;
const ns = await api.nodesSummary();
const availableNodes = Array.isArray(ns?.nodes)
? Array.from(new Set(ns.nodes.map(n => String(n.name || n.node || n).trim()).filter(Boolean)))
: (Array.isArray(ns) ? Array.from(new Set(ns.map(n => String(n.name || n.node || n).trim()).filter(Boolean))) : []);
setRows(tbody, []);
// initial table fill
try {
const latest = await api.listAllVmct();
const all = Array.isArray(latest.all) ? latest.all : [];
const nodesNow = Array.isArray(latest.nodes) ? latest.nodes : [];
window.__nodesCache = nodesNow.slice();
const rows = all.map(vm => ({
sid: safe(vm.sid),
name: safe(vm.name || vm.vmid || vm.sid),
node: safe(vm.node || '—'),
status: safe(vm.status || '—')
}));
const html = rows.map(r => rowHTML([
`<input type="checkbox" class="row-check">`,
safe(r.sid),
safe(r.name),
safe(r.node),
badge(safe(r.status), /running|online|started/i.test(r.status) ? 'ok' : 'dark'),
`<select class="form-select form-select-sm target-node"></select>`,
`<div class="btn-group">
<button class="btn btn-sm btn-primary act-migrate" disabled>MIGRATE</button>
<button class="btn btn-sm btn-outline-secondary act-shutdown">Shutdown</button>
<button class="btn btn-sm btn-outline-danger act-stop">Stop</button>
</div>`
])).join('');
setRows(tbody, html);
// wire node selects and actions
Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => {
const nodeCell = tr.children[3];
const targetSel = tr.querySelector('.target-node');
rebuildTargetSelect(targetSel, nodeCell?.textContent.trim(), availableNodes);
const sid = tr.getAttribute('data-sid');
const nameCell = tr.children[2];
async function doAction(kind, needsTarget) {
try {
const targetNode = needsTarget ? targetSel?.value : undefined;
activeSids.add(sid);
setBadgeCell(tr.children[4], 'working');
setMigrateDisabled(tr, false);
const res = await api.vmAction(sid, kind, targetNode);
if (res?.ok) {
showToast(`Task ${kind} started for ${safe(nameCell.textContent)}`);
} else {
showToast(`Task ${kind} failed for ${safe(nameCell.textContent)}`, 'danger');
}
} catch (e) {
showToast(`Error: ${e?.message || e}`, 'danger');
}
}
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();
// full refresh every 30s
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 from slow reconcile — only when not 'working' to avoid overruling WS
const currentTxt = (statusCell?.innerText || '').toLowerCase();
if (!/working/.test(currentTxt)) {
const stRaw = String(rowData.status || '').toLowerCase(); // fallback z /cluster/resources
if (stRaw) {
const changed = setBadgeCell(statusCell, stRaw);
const isRunning = /running|online|started/.test(stRaw);
setMigrateDisabled(tr, isRunning);
if (changed) flashDot(nameCell);
}
}
});
} catch {}
}, 30000);
// active only every 10s (pull precise 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 });
} catch (e) {
showToast(`Failed to load list: ${e?.message || e}`, 'danger');
}
}
// Entry point expected by main.js
export async function renderVMAdmin() {
try {
await startAdminWatches();
} catch (e) {
showToast(`VM Admin initialization error: ${e?.message || e}`, 'danger');
console.error(e);
}
}