Files
pve-ha-web/static/js/admin.js
Mateusz Gruszczyński fda2b721b3 refator_comm1
2025-10-18 22:27:39 +02:00

224 lines
8.3 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 } 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 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;
}
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 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 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">Details</button>
</div>`;
// Kolumny: SID | TYPE | NAME | NODE | ACTIONS | TARGET | TOOLS
return rowHTML([sid, type.toUpperCase(), nameCell, node, 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 nodeCell = tr.children[3];
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 === 'task-start') {
activeSids.add(sid);
flashDot(nameCell);
}
else if (msg.type === 'task') {
activeSids.add(sid);
flashDot(nameCell);
}
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') {
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') 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));
tr.querySelector('.act-status')?.addEventListener('click', async () => {
flashDot(nameCell);
});
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 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);
}
});
} catch {}
}, 30000);
// tylko aktywne co 10 s
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 nameCell = tr.children[2];
const targetSel = tr.querySelector('.target-node');
const newNode = String(detail.node || (detail.meta && detail.meta.node) || '').trim();
if (newNode) {
if (nodeCell && nodeCell.textContent.trim() !== newNode) {
nodeCell.textContent = newNode;
flashDot(nameCell);
}
rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []);
}
setTimeout(() => activeSids.delete(sid), 4000);
}
} catch {}
}, 10000);
window.addEventListener('beforeunload', stopAllAdminWatches, { once: true });
}