Files
pve-ha-web/static/main.js
Mateusz Gruszczyński 7441e64be4 vm management
2025-10-17 16:31:42 +02:00

902 lines
40 KiB
JavaScript

// ------ helpers ------
const tblVmAdmin = document.querySelector("#vm-admin");
const $ = (q) => document.querySelector(q);
function safe(v) { return (v === undefined || v === null || v === '') ? '—' : String(v); }
function ensureArr(a) { return Array.isArray(a) ? a : []; }
function pct(p) { if (p == null) return '—'; return (p * 100).toFixed(1) + '%'; }
function humanBytes(n) { if (n == null) return '—'; const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; let i = 0, x = +n; while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; } return x.toFixed(1) + ' ' + u[i]; }
function badge(txt, kind) {
const cls = {
ok: 'bg-success-subtle text-success-emphasis', warn: 'bg-warning-subtle text-warning-emphasis',
err: 'bg-danger-subtle text-danger-emphasis', info: 'bg-info-subtle text-info-emphasis',
dark: 'bg-secondary-subtle text-secondary-emphasis'
}[kind || 'dark'];
return `<span class="badge rounded-pill ${cls}">${safe(txt)}</span>`;
}
function rowHTML(cols, attrs = '') { return `<tr ${attrs}>${cols.map(c => `<td>${c ?? '—'}</td>`).join('')}</tr>`; }
function setRows(tbody, rows) { tbody.innerHTML = rows.length ? rows.join('') : rowHTML(['—']); }
function fmtSeconds(s) {
if (s == null) return '—';
s = Math.floor(s);
const d = Math.floor(s / 86400); s %= 86400;
const h = Math.floor(s / 3600); s %= 3600;
const m = Math.floor(s / 60); s %= 60;
const parts = [h.toString().padStart(2, '0'), m.toString().padStart(2, '0'), s.toString().padStart(2, '0')].join(':');
return d > 0 ? `${d}d ${parts}` : parts;
}
function parseNetConf(val) {
const out = {}; if (!val) return out;
val.split(',').forEach(kv => {
const [k, v] = kv.split('=');
if (k && v !== undefined) out[k.trim()] = v.trim();
});
return out;
}
function parseVmNetworks(config) {
const nets = [];
for (const [k, v] of Object.entries(config || {})) {
const m = k.match(/^net(\d+)$/);
if (m) { nets.push({ idx: +m[1], raw: v, ...parseNetConf(v) }); }
}
nets.sort((a, b) => a.idx - b.idx);
return nets;
}
function kvGrid(obj, keys, titleMap = {}) {
return `<div class="row row-cols-1 row-cols-md-2 g-2">
${keys.map(k => `
<div class="col">
<div class="card border-0"><div class="card-body p-2">
<div class="text-muted small">${titleMap[k] || k}</div>
<div class="fw-semibold">${safe(obj[k])}</div>
</div></div>
</div>`).join('')}
</div>`;
}
// prefer first non-empty
function pick(...vals) { for (const v of vals) { if (v !== undefined && v !== null && v !== '') return v; } return ''; }
// ------ DOM refs ------
const nodeInput = $('#node'), btnEnable = $('#btnEnable'), btnDisable = $('#btnDisable'), btnToggleAll = $('#btnToggleAll');
const btnRefresh = $('#btnRefresh'), btnAuto = $('#btnAuto'), selInterval = $('#selInterval');
const healthDot = $('#healthDot'), healthTitle = $('#healthTitle'), healthSub = $('#healthSub');
const qSummary = $('#q-summary'), qCardsWrap = $('#q-cards'), unitsBox = $('#units'), replBox = $('#repl');
const tblHaRes = $('#ha-res'), tblHaStatus = $('#ha-status'), tblNodes = $('#nodes'), tblNonHA = $('#nonha');
const pvecmPre = $('#pvecm'), cfgtoolPre = $('#cfgtool'), footer = $('#footer');
// ------ actions ------
async function callAction(act) {
const node = nodeInput.value || '';
const r = await fetch('/api/' + act, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ node }) });
const d = await r.json(); alert(d.ok ? 'OK' : ('ERROR: ' + (d.error || 'unknown')));
}
btnEnable.onclick = () => callAction('enable');
btnDisable.onclick = () => callAction('disable');
btnToggleAll.onclick = () => {
document.querySelectorAll('.accordion-collapse').forEach(el => {
const bs = bootstrap.Collapse.getOrCreateInstance(el, { toggle: false });
el.classList.contains('show') ? bs.hide() : bs.show();
});
};
// ------ refresh control ------
let REF_TIMER = null;
async function fetchSnapshot() {
const r = await fetch('/api/info?node=' + encodeURIComponent(nodeInput.value || ''));
return await r.json();
}
async function doRefresh() {
const d = await fetchSnapshot();
renderSnap(d);
if (!doRefresh.didNonHA) { await renderNonHA(); doRefresh.didNonHA = true; }
if (!doRefresh.didAdmin) { await renderVMAdmin(); doRefresh.didAdmin = true; }
}
btnRefresh.onclick = doRefresh;
btnAuto.onclick = () => {
if (REF_TIMER) {
clearInterval(REF_TIMER); REF_TIMER = null;
btnAuto.textContent = 'OFF';
btnAuto.classList.remove('btn-success'); btnAuto.classList.add('btn-outline-success');
selInterval.disabled = true;
} else {
const iv = parseInt(selInterval.value || '30000', 10);
REF_TIMER = setInterval(doRefresh, iv);
btnAuto.textContent = 'ON';
btnAuto.classList.remove('btn-outline-success'); btnAuto.classList.add('btn-success');
selInterval.disabled = false;
}
};
selInterval.onchange = () => {
if (REF_TIMER) {
clearInterval(REF_TIMER);
REF_TIMER = setInterval(doRefresh, parseInt(selInterval.value || '30000', 10));
}
};
// ------ VM detail API ------
async function fetchVmDetail(sid) {
const r = await fetch('/api/vm?sid=' + encodeURIComponent(sid));
return await r.json();
}
// ------ Node detail API ------
async function fetchNodeDetail(name) {
const r = await fetch('/api/node?name=' + encodeURIComponent(name));
return await r.json();
}
// ------ VM detail card ------
function renderVmDetailCard(d) {
const meta = d.meta || {};
const cur = d.current || {};
const cfg = d.config || {};
const ag = d.agent || {};
const agInfo = ag.info || null;
const agOS = ag.osinfo && ag.osinfo.result ? ag.osinfo.result : null;
const agIfs = ag.ifaces && ag.ifaces.result ? ag.ifaces.result : null;
const statusBadge = /running|online|started/i.test(meta.status || cur.status || '')
? badge(meta.status || cur.status || 'running', 'ok')
: badge(meta.status || cur.status || 'stopped', 'err');
const maxmem = cur.maxmem ?? (cfg.memory ? Number(cfg.memory) * 1024 * 1024 : null);
const used = cur.mem ?? null;
const free = (maxmem != null && used != null) ? Math.max(0, maxmem - used) : null;
const balloonEnabled = (cfg.balloon !== undefined) ? (Number(cfg.balloon) !== 0) : (cur.balloon !== undefined && Number(cur.balloon) !== 0);
const binfo = cur.ballooninfo || null;
let guestName = agOS && (agOS.name || agOS.pretty_name) || (agInfo && agInfo.version) || '';
let guestIPs = [];
if (Array.isArray(agIfs)) {
agIfs.forEach(i => {
(i['ip-addresses'] || []).forEach(ip => {
const a = ip['ip-address']; if (a && !a.startsWith('fe80')) guestIPs.push(a);
});
});
}
const bstat = cur.blockstat || {};
const bRows = Object.keys(bstat).sort().map(dev => {
const s = bstat[dev] || {};
return rowHTML([
dev, humanBytes(s.rd_bytes || 0), safe(s.rd_operations || 0),
humanBytes(s.wr_bytes || 0), safe(s.wr_operations || 0),
safe(s.flush_operations || 0), humanBytes(s.wr_highest_offset || 0)
]);
});
const ha = cur.ha || {};
const haBadge = ha.state ? (/started/i.test(ha.state) ? badge(ha.state, 'ok') : badge(ha.state, 'warn')) : badge('—', 'dark');
const sysCards = {
'QMP status': cur.qmpstatus,
'QEMU': cur['running-qemu'],
'Machine': cur['running-machine'],
'PID': cur.pid,
'Pressure CPU (some/full)': `${safe(cur.pressurecpusome)}/${safe(cur.pressurecpufull)}`,
'Pressure IO (some/full)': `${safe(cur.pressureiosome)}/${safe(cur.pressureiofull)}`,
'Pressure MEM (some/full)': `${safe(cur.pressurememorysome)}/${safe(cur.pressurememoryfull)}`
};
const nets = parseVmNetworks(cfg);
const netRows = nets.map(n => {
const br = n.bridge || n.br || '—';
const mdl = n.model || n.type || (n.raw?.split(',')[0]?.split('=')[0]) || 'virtio';
const mac = n.hwaddr || n.mac || (n.raw?.match(/([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}/)?.[0] || '—');
const vlan = n.tag || n.vlan || '—';
const fw = (n.firewall === '1') ? badge('on', 'warn') : badge('off', 'dark');
return rowHTML([`net${n.idx}`, mdl, br, vlan, mac, fw]);
});
const netTable = `
<div class="table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap">
<thead><tr><th>IF</th><th>Model</th><th>Bridge</th><th>VLAN</th><th>MAC</th><th>FW</th></tr></thead>
<tbody>${netRows.length ? netRows.join('') : rowHTML(['—', '—', '—', '—', '—', '—'])}</tbody>
</table>
</div>`;
const agentSummary = (agInfo || agOS || guestIPs.length)
? `<div class="small">
${agOS ? `<div>Guest OS: <strong>${safe(guestName)}</strong></div>` : ''}
${guestIPs.length ? `<div>Guest IPs: ${guestIPs.map(ip => badge(ip, 'info')).join(' ')}</div>` : ''}
${agInfo ? `<div>Agent: ${badge('present', 'ok')}</div>` : `<div>Agent: ${badge('not available', 'err')}</div>`}
</div>`
: '<div class="text-muted small">No guest agent data</div>';
const cfgFacts = {
'BIOS': cfg.bios, 'UEFI/EFI disk': cfg.efidisk0 ? 'yes' : 'no',
'CPU type': cfg.cpu, 'Sockets': cfg.sockets, 'Cores': cfg.cores, 'NUMA': cfg.numa,
'On boot': cfg.onboot ? 'yes' : 'no', 'OS type': cfg.ostype, 'SCSI hw': cfg.scsihw
};
// Collapsible raw JSON
const rawId = `raw-${d.type}-${d.vmid}`;
const rawBtn = `
<button class="btn btn-sm btn-outline-secondary mt-3" type="button"
data-bs-toggle="collapse" data-bs-target="#${rawId}">
Show raw JSON
</button>`;
const rawBox = `
<div id="${rawId}" class="collapse mt-2">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#rt-${d.vmid}" type="button">Runtime</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#cfg-${d.vmid}" type="button">Config</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#agt-${d.vmid}" type="button">Agent</button></li>
</ul>
<div class="tab-content border-top pt-3">
<div class="tab-pane fade show active" id="rt-${d.vmid}"><pre class="small mb-0">${JSON.stringify(cur, null, 2)}</pre></div>
<div class="tab-pane fade" id="cfg-${d.vmid}"><pre class="small mb-0">${JSON.stringify(cfg, null, 2)}</pre></div>
<div class="tab-pane fade" id="agt-${d.vmid}"><pre class="small mb-0">${JSON.stringify(ag, null, 2)}</pre></div>
</div>
</div>`;
return `
<div class="d-flex flex-wrap align-items-center gap-3 mb-2">
<div class="fw-bold">${safe(meta.name || cfg.name || d.sid)}</div>
<div class="text-muted small">${(d.type || '').toUpperCase()} / VMID ${safe(d.vmid)} @ ${safe(d.node)}</div>
<div class="vr"></div>
<div>${statusBadge}</div>
${meta.hastate ? `<div class="vr"></div><div class="small">HA: ${badge(meta.hastate, /started/i.test(meta.hastate) ? 'ok' : 'warn')}</div>` : ''}
${ha.state ? `<div class="vr"></div><div class="small">HA runtime: ${haBadge}</div>` : ''}
</div>
<div class="row row-cols-2 row-cols-md-4 g-2">
<div class="col"><div class="card border-0"><div class="card-body p-2">
<div class="text-muted small">CPU</div><div class="fw-semibold">${cur.cpu !== undefined ? (cur.cpu * 100).toFixed(1) + '%' : '—'}</div>
<div class="small text-muted">vCPUs: ${safe(cur.cpus ?? cfg.cores ?? cfg.sockets)}</div>
</div></div></div>
<div class="col"><div class="card border-0"><div class="card-body p-2">
<div class="text-muted small">Memory (used/free/total)</div>
<div class="fw-semibold">${(used != null && maxmem != null)
? `${humanBytes(used)} / ${humanBytes(free)} / ${humanBytes(maxmem)}`
: '—'}</div>
<div class="small mt-1">Ballooning: ${balloonEnabled ? badge('enabled', 'ok') : badge('disabled', 'err')}</div>
${binfo ? `<div class="small text-muted mt-1">Balloon actual: ${humanBytes(binfo.actual)} | guest free: ${humanBytes(binfo.free_mem)} | guest total: ${humanBytes(binfo.total_mem)}</div>` : ''}
</div></div></div>
<div class="col"><div class="card border-0"><div class="card-body p-2">
<div class="text-muted small">Disk (used/total)</div>
<div class="fw-semibold">${(cur.disk != null && cur.maxdisk != null) ? `${humanBytes(cur.disk)} / ${humanBytes(cur.maxdisk)}` : '—'}</div>
<div class="small text-muted mt-1">R: ${humanBytes(cur.diskread || 0)} | W: ${humanBytes(cur.diskwrite || 0)}</div>
</div></div></div>
<div class="col"><div class="card border-0"><div class="card-body p-2">
<div class="text-muted small">Uptime</div><div class="fw-semibold">${fmtSeconds(cur.uptime)}</div>
<div class="small text-muted mt-1">Tags: ${safe(cfg.tags || meta.tags || '—')}</div>
</div></div></div>
</div>
<div class="mt-3">
<div class="fw-semibold mb-1">Network (config)</div>
${netTable}
</div>
<div class="mt-3">
<div class="fw-semibold mb-1">Disks (block statistics)</div>
<div class="table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap">
<thead><tr><th>Device</th><th>Read bytes</th><th>Read ops</th><th>Write bytes</th><th>Write ops</th><th>Flush ops</th><th>Highest offset</th></tr></thead>
<tbody>${Object.keys(bstat).length ? bRows.join('') : rowHTML(['—', '—', '—', '—', '—', '—', '—'])}</tbody>
</table>
</div>
</div>
<div class="mt-3">
<div class="fw-semibold mb-1">System / QEMU</div>
${kvGrid(sysCards, Object.keys(sysCards))}
</div>
<div class="mt-3 mb-1"><div class="fw-semibold">Config facts</div>${kvGrid(cfgFacts, Object.keys(cfgFacts))}</div>
<div class="mt-2">${agentSummary}</div>
${rawBtn}
${rawBox}
`;
}
// ------ Node detail card ------
function renderNodeDetailCard(d) {
const st = d.status || {}; lastSt = st;
const ver = d.version || {};
const tm = d.time || {};
const netcfg = ensureArr(d.network_cfg);
const disks = ensureArr(d.disks);
const subscription = d.subscription || {}; // <-- JEDYNA deklaracja (zamiast podwójnego 'const sub')
// robust online detection
const isOn = /online|running/i.test(st.status || '') ||
/online/i.test(st.hastate || '') ||
(st.uptime > 0) ||
(st.cpu != null && st.maxcpu != null) ||
(st.memory && st.memory.total > 0);
const statusTxt = isOn ? 'online' : (st.status || 'offline');
const sB = isOn ? badge(statusTxt, 'ok') : badge(statusTxt, 'err');
const mem = st.memory || {};
const root = st.rootfs || {};
const load = Array.isArray(st.loadavg) ? st.loadavg.join(' ') : (st.loadavg || '');
// ---- SYSTEM DETAILS
const cpuinfo = st.cpuinfo || {};
const boot = st['boot-info'] || st.boot_info || {};
const curKernel = st['current-kernel'] || st.current_kernel || {};
const ramStr = (mem.used != null && mem.available != null && mem.total != null)
? `${humanBytes(mem.used)} used / ${humanBytes(mem.available)} free / ${humanBytes(mem.total)} total`
: (mem.total != null ? humanBytes(mem.total) : '—');
const tech = {
'PVE version': pick(st.pveversion, ver.pvemanager, ver['pve-manager']),
'Kernel': pick(st.kversion, curKernel.release, ver.kernel, ver.release),
'CPU model': pick(cpuinfo.model, st['cpu-model'], ver['cpu-model'], ver.cpu),
'Architecture': pick(curKernel.machine, ver.arch, st.architecture, st.arch),
'RAM': ramStr,
'Boot mode': pick(boot.mode) ? String(boot.mode).toUpperCase() : '—',
'Secure Boot': (boot.secureboot === 1 || boot.secureboot === '1') ? 'enabled' :
(boot.secureboot === 0 || boot.secureboot === '0') ? 'disabled' : '—'
};
const top = `
<div class="d-flex flex-wrap align-items-center gap-3 mb-2">
<div class="fw-bold">${safe(d.node)}</div>
<div class="vr"></div>
<div>${sB}</div>
<div class="vr"></div>
<div class="small text-muted">CPU: ${pct(st.cpu)}</div>
<div class="small text-muted">Load: ${safe(load)}</div>
<div class="small text-muted">Uptime: ${fmtSeconds(st.uptime)}</div>
</div>`;
const memCard = `
<div class="row row-cols-2 row-cols-md-4 g-2">
<div class="col"><div class="card border-0"><div class="card-body p-2">
<div class="text-muted small">Memory</div>
<div class="fw-semibold">${(mem.used != null && mem.total != null) ? `${humanBytes(mem.used)} / ${humanBytes(mem.total)} (${pct(mem.used / mem.total)})` : '—'}</div>
</div></div></div>
<div class="col"><div class="card border-0"><div class="card-body p-2">
<div class="text-muted small">RootFS</div>
<div class="fw-semibold">${(root.used != null && root.total != null) ? `${humanBytes(root.used)} / ${humanBytes(root.total)} (${pct(root.used / root.total)})` : '—'}</div>
</div></div></div>
<div class="col"><div class="card border-0"><div class="card-body p-2">
<div class="text-muted small">Kernel / QEMU</div>
<div class="fw-semibold">${safe(tech['Kernel'])} / ${safe(pick(ver.qemu, ver['running-qemu']))}</div>
</div></div></div>
<div class="col"><div class="card border-0"><div class="card-body p-2">
<div class="text-muted small">Time</div>
<div class="fw-semibold">${safe(tm.localtime)} ${tm.timezone ? `(${tm.timezone})` : ''}</div>
</div></div></div>
</div>`;
const sysDetails = kvGrid(tech, Object.keys(tech), {
'PVE version': 'PVE version',
'Kernel': 'Kernel version',
'CPU model': 'CPU model',
'Architecture': 'Arch',
'RAM': 'RAM (used/free/total)',
'Boot mode': 'Boot mode',
'Secure Boot': 'Secure Boot'
});
// Network config
const netRows = netcfg.map(n => {
return rowHTML([safe(n.iface || n.ifname), safe(n.type), safe(n.method || n.autostart), safe(n.bridge_ports || n.address || '—'), safe(n.cidr || n.netmask || '—'), safe(n.comments || '')]);
});
const netCfgTable = `
<div class="table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap">
<thead><tr><th>IF</th><th>Type</th><th>Method</th><th>Ports/Address</th><th>Netmask/CIDR</th><th>Comment</th></tr></thead>
<tbody>${netRows.length ? netRows.join('') : rowHTML(['—', '—', '—', '—', '—', '—'])}</tbody>
</table>
</div>`;
// Disks
const diskRows = disks.map(dv => rowHTML([safe(dv.devpath || dv.kname || dv.dev), safe(dv.model), safe(dv.size ? humanBytes(dv.size) : '—'), safe(dv.health || dv.wearout || '—'), safe(dv.serial || '—')]));
const diskTable = `
<div class="table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap">
<thead><tr><th>Device</th><th>Model</th><th>Size</th><th>Health</th><th>Serial</th></tr></thead>
<tbody>${diskRows.length ? diskRows.join('') : rowHTML(['—', '—', '—', '—', '—'])}</tbody>
</table>
</div>`;
// Subscription
const subBox = `
<div class="small">
<div>Status: ${badge(safe(subscription.status || 'unknown'), /active|valid/i.test(subscription.status || '') ? 'ok' : 'warn')}</div>
${subscription.productname ? `<div>Product: <strong>${safe(subscription.productname)}</strong></div>` : ''}
${subscription.message ? `<div class="text-muted">${safe(subscription.message)}</div>` : ''}
</div>`;
// Collapsible raw JSON
const rawId = `raw-node-${safe(d.node)}`;
const rawBtn = `
<button class="btn btn-sm btn-outline-secondary mt-3" type="button"
data-bs-toggle="collapse" data-bs-target="#${rawId}">
Show raw JSON
</button>`;
const rawBox = `
<div id="${rawId}" class="collapse mt-2">
<pre class="small mb-0">${JSON.stringify(d, null, 2)}</pre>
</div>`;
return `
${top}
${memCard}
<div class="mt-3">
<div class="fw-semibold mb-1">System details</div>
${sysDetails}
</div>
<div class="mt-3">
<div class="fw-semibold mb-1">Network (config)</div>
${netCfgTable}
</div>
<div class="mt-3">
<div class="fw-semibold mb-1">Disks</div>
${diskTable}
</div>
<div class="mt-3">
<div class="fw-semibold mb-1">Subscription</div>
${subBox}
</div>
${rawBtn}
${rawBox}
`;
}
// ------ Sections ------
function setHealth(ok, vq, unitsActive) {
healthDot.classList.toggle('ok', !!ok);
healthDot.classList.toggle('bad', !ok);
healthTitle.textContent = ok ? 'HA: OK' : 'HA: PROBLEM';
healthSub.textContent = `Quorate=${String(vq.quorate)} | units=${unitsActive ? 'active' : 'inactive'} | members=${safe(vq.members)} | quorum=${safe(vq.quorum)}/${safe(vq.expected)}`;
}
function renderClusterCards(arr) {
const a = ensureArr(arr); qCardsWrap.innerHTML = '';
if (!a.length) { qCardsWrap.innerHTML = badge('No data', 'dark'); return; }
const cluster = a.find(x => x.type === 'cluster') || {};
const qB = cluster.quorate ? badge('Quorate: yes', 'ok') : badge('Quorate: no', 'err');
qCardsWrap.insertAdjacentHTML('beforeend', `
<div class="col-12">
<div class="card border-0 shadow-sm"><div class="card-body d-flex flex-wrap align-items-center gap-3">
<div class="fw-bold">${safe(cluster.name)}</div>
<div class="text-muted small">id: ${safe(cluster.id)}</div><div class="vr"></div>
<div>${qB}</div><div class="vr"></div>
<div class="small">nodes: <strong>${safe(cluster.nodes)}</strong></div>
<div class="small">version: <strong>${safe(cluster.version)}</strong></div>
</div></div>
</div>`);
const nodes = a.filter(x => x.type === 'node');
const rows = nodes.map(n => {
const online = n.online ? badge('online', 'ok') : badge('offline', 'err');
const local = n.local ? ' ' + badge('local', 'info') : '';
return rowHTML([safe(n.name), online + local, safe(n.ip), safe(n.nodeid), safe(n.level)]);
});
qCardsWrap.insertAdjacentHTML('beforeend', `
<div class="col-12"><div class="card border-0"><div class="card-body table-responsive pt-2">
<table class="table table-sm table-striped align-middle table-nowrap">
<thead><tr><th>Node</th><th>Status</th><th>IP</th><th>NodeID</th><th>Level</th></tr></thead>
<tbody>${rows.join('')}</tbody>
</table>
</div></div></div>`);
}
function renderUnits(units) {
unitsBox.innerHTML = '';
if (!units || !Object.keys(units).length) { unitsBox.innerHTML = badge('No data', 'dark'); return; }
const map = { active: 'ok', inactive: 'err', failed: 'err', activating: 'warn' };
Object.entries(units).forEach(([k, v]) => unitsBox.insertAdjacentHTML('beforeend', `<span class="me-2">${badge(k, map[v] || 'dark')}</span>`));
}
function parsePveSr(text) {
const lines = (text || '').split('\n').map(s => s.trim()).filter(Boolean), out = [];
for (const ln of lines) {
if (ln.startsWith('JobID')) continue;
const m = ln.match(/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+)$/);
if (m) out.push({ job: m[1], enabled: m[2], target: m[3], last: m[4], next: m[5], dur: m[6], fail: +m[7], state: m[8] });
} return out;
}
function renderReplication(text) {
const arr = parsePveSr(text);
if (!arr.length) { replBox && (replBox.innerHTML = '<span class="text-muted small">No replication jobs</span>'); return; }
const rows = arr.map(x => {
const en = /^yes$/i.test(x.enabled) ? badge('Yes', 'ok') : badge('No', 'err');
const st = /^ok$/i.test(x.state) ? badge(x.state, 'ok') : badge(x.state, 'err');
const fc = x.fail > 0 ? badge(String(x.fail), 'err') : badge(String(x.fail), 'ok');
return rowHTML([x.job, en, x.target, x.last, x.next, x.dur, fc, st]);
});
replBox.innerHTML = `<div class="table-responsive"><table class="table table-sm table-striped align-middle table-nowrap">
<thead><tr><th>JobID</th><th>Enabled</th><th>Target</th><th>LastSync</th><th>NextSync</th><th>Duration</th><th>FailCount</th><th>State</th></tr></thead>
<tbody>${rows.join('')}</tbody></table></div>`;
}
// HA Resources table (expandable rows)
function renderHAResources(list) {
const arr = ensureArr(list); const rows = [];
arr.forEach(x => {
const st = x.state || '—';
const stB = /start/i.test(st) ? badge(st, 'ok') : (/stop/i.test(st) ? badge(st, 'err') : badge(st, 'dark'));
const sid = safe(x.sid);
rows.push(rowHTML([sid, stB, safe(x.node), safe(x.group), safe(x.flags || x.comment)], `class="vm-row" data-sid="${sid}"`));
rows.push(`<tr class="vm-detail d-none"><td colspan="5"><div class="spinner-border spinner-border-sm me-2 d-none"></div><div class="vm-json">—</div></td></tr>`);
});
setRows(tblHaRes, rows.length ? rows : [rowHTML(['—', '—', '—', '—', '—'])]);
Array.from(document.querySelectorAll('#ha-res tr.vm-row')).forEach((tr, i) => {
tr.onclick = async () => {
const detailRow = tblHaRes.querySelectorAll('tr.vm-detail')[i];
const content = detailRow.querySelector('.vm-json');
const spin = detailRow.querySelector('.spinner-border');
const open = detailRow.classList.contains('d-none');
document.querySelectorAll('#ha-res tr.vm-detail').forEach(r => r.classList.add('d-none'));
if (open) {
detailRow.classList.remove('d-none'); spin.classList.remove('d-none');
const sid = tr.getAttribute('data-sid');
try { const d = await fetchVmDetail(sid); content.innerHTML = renderVmDetailCard(d); }
catch (e) { content.textContent = 'ERROR: ' + e; }
spin.classList.add('d-none');
}
};
});
}
function renderHAStatus(list) {
const st = ensureArr(list);
if (!st.length) { setRows(tblHaStatus, [rowHTML(['—', '—', '—', '—'])]); return; }
const rows = st.map(n => {
const sB = /active|online/i.test(n.state || '') ? badge(n.state, 'ok') : badge(safe(n.state || '—'), 'warn');
const crmB = /active/i.test(n.crm_state || '') ? badge(n.crm_state, 'ok') : badge(safe(n.crm_state || '—'), 'err');
const lrmB = /active/i.test(n.lrm_state || '') ? badge(n.lrm_state, 'ok') : badge(safe(n.lrm_state || '—'), 'err');
return rowHTML([safe(n.node), sB, crmB, lrmB]);
});
setRows(tblHaStatus, rows);
}
// Non-HA VM/CT table
async function renderNonHA() {
const r = await fetch('/api/list-vmct');
const d = await r.json();
const arr = ensureArr(d.nonha);
if (!arr.length) { setRows(tblNonHA, [rowHTML(['No non-HA VMs/CTs'])]); return; }
const rows = [];
arr.forEach(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');
rows.push(rowHTML([sid, type, name, node, st], `class="vm-row" data-sid="${sid}` + "\""));
rows.push(`<tr class="vm-detail d-none"><td colspan="5"><div class="spinner-border spinner-border-sm me-2 d-none"></div><div class="vm-json">—</div></td></tr>`);
});
setRows(tblNonHA, rows);
Array.from(document.querySelectorAll('#nonha tr.vm-row')).forEach((tr, i) => {
tr.onclick = async () => {
const detailRow = tblNonHA.querySelectorAll('tr.vm-detail')[i];
const content = detailRow.querySelector('.vm-json');
const spin = detailRow.querySelector('.spinner-border');
const open = detailRow.classList.contains('d-none');
document.querySelectorAll('#nonha tr.vm-detail').forEach(r => r.classList.add('d-none'));
if (open) {
detailRow.classList.remove('d-none'); spin.classList.remove('d-none');
const sid = tr.getAttribute('data-sid');
try { const d = await fetchVmDetail(sid); content.innerHTML = renderVmDetailCard(d); }
catch (e) { content.textContent = 'ERROR: ' + e; }
spin.classList.add('d-none');
}
};
});
}
// Nodes table (expandable) + sticky first column + robust online detect
function renderNodesTable(nodes) {
const nrows = ensureArr(nodes).map(n => {
const isOn = /online|running/i.test(n.status || '') ||
/online/i.test(n.hastate || '') ||
(n.uptime > 0) ||
(n.cpu != null && n.maxcpu != null) ||
(n.mem != null && n.maxmem != null);
const statusTxt = isOn ? 'online' : (n.status || 'offline');
const sB = isOn ? badge(statusTxt, 'ok') : badge(statusTxt, 'err');
const mem = (n.mem != null && n.maxmem) ? `${humanBytes(n.mem)} / ${humanBytes(n.maxmem)} (${pct(n.mem / n.maxmem)})` : '—';
const rfs = (n.rootfs != null && n.maxrootfs) ? `${humanBytes(n.rootfs)} / ${humanBytes(n.maxrootfs)} (${pct(n.rootfs / n.maxrootfs)})` : '—';
const load = (n.loadavg != null) ? String(n.loadavg) : '—';
const cpu = (n.cpu != null) ? pct(n.cpu) : '—';
const main = `<tr class="node-row" data-node="${safe(n.node)}">
<td class="sticky-col fw-semibold">${safe(n.node)}</td>
<td>${sB}</td><td>${cpu}</td><td>${load}</td><td>${mem}</td><td>${rfs}</td><td>${fmtSeconds(n.uptime)}</td>
</tr>`;
const detail = `<tr class="node-detail d-none">
<td colspan="7">
<div class="spinner-border spinner-border-sm me-2 d-none"></div>
<div class="node-json">—</div>
</td>
</tr>`;
return main + detail;
});
setRows(tblNodes, nrows);
Array.from(document.querySelectorAll('#nodes tr.node-row')).forEach((tr, i) => {
tr.onclick = async () => {
const detailRow = tblNodes.querySelectorAll('tr.node-detail')[i];
const content = detailRow.querySelector('.node-json');
const spin = detailRow.querySelector('.spinner-border');
const open = detailRow.classList.contains('d-none');
document.querySelectorAll('#nodes tr.node-detail').forEach(r => r.classList.add('d-none'));
if (open) {
detailRow.classList.remove('d-none'); spin.classList.remove('d-none');
const name = tr.getAttribute('data-node');
try { const d = await fetchNodeDetail(name); content.innerHTML = renderNodeDetailCard(d); }
catch (e) { content.textContent = 'ERROR: ' + e; }
spin.classList.add('d-none');
}
};
});
}
// ------ main render ------
function renderSnap(d) {
const vq = d.votequorum || {}; const units = d.units || {}; const allUnits = Object.values(units).every(v => v === 'active');
const ok = (vq.quorate === 'yes') && allUnits;
setHealth(ok, vq, allUnits);
const gl = document.getElementById('global-loading'); if (gl) gl.remove();
qSummary.textContent = `Quorate: ${safe(vq.quorate)} | members: ${safe(vq.members)} | expected: ${safe(vq.expected)} | total: ${safe(vq.total)} | quorum: ${safe(vq.quorum)}`;
renderClusterCards(d.api && d.api.cluster_status);
renderUnits(units);
renderReplication(d.replication);
renderHAResources(d.api && d.api.ha_resources);
renderHAStatus(d.api && d.api.ha_status);
renderNodesTable(d.api && d.api.nodes);
pvecmPre.textContent = safe(d.pvecm);
cfgtoolPre.textContent = safe(d.cfgtool);
footer.textContent = `node_arg=${safe(d.node_arg)} | host=${safe(d.hostname)} | ts=${new Date((d.ts || 0) * 1000).toLocaleString()}`;
}
// initial one-shot load (auto refresh OFF by default)
doRefresh().catch(console.error);
// ------ VM Admin API/UI ------
async function fetchAllVmct() {
const r = await fetch('/api/list-all-vmct');
return await r.json();
}
async function vmAction(sid, action, target) {
const body = { sid, action };
if (target) body.target = target;
const r = await fetch('/api/vm-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'action failed');
return d;
}
async function renderVMAdmin() {
const data = await fetchAllVmct();
const arr = ensureArr(data.all);
const nodes = ensureArr(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:140px">
${nodes.map(n => `<option value="${n}" ${n === x.node ? 'disabled' : ''}>${n}${n === x.node ? ' (src)' : ''}</option>`).join('')}
</select>`;
const migrateBtn = `<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></div>`;
return rowHTML([sid, type.toUpperCase(), name, node, st, 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 getTarget = () => tr.querySelector('.target-node')?.value || '';
const colSpan = tr.children.length; // for status row width
const bind = (sel, action, needsTarget = false) => {
const btn = tr.querySelector(sel);
if (!btn) return;
btn.onclick = async () => {
setRowBusy(tr, true);
btn.disabled = true;
try {
if (action === 'migrate') {
const target = getTarget();
const resp = await vmAction(sid, action, target);
// show expandable status row (spinner already set via setRowBusy)
const row = ensureMigRow(tr, colSpan);
setMigRowVisible(row, true);
const log = row.querySelector('.mig-log');
const srcNode = resp.source_node;
const upid = resp.upid;
const stopSig = { done: false };
tailTaskLog(upid, srcNode, log, stopSig);
log.textContent = `Starting offline migrate to ${target} (UPID: ${upid || '—'})...`;
// update badge immediately
const badgeCell = tr.children[4];
if (badgeCell) badgeCell.innerHTML = badge('migrating', 'info');
// start background polling; update log on each tick
await pollTask(upid, srcNode, (st) => {
const lines = [];
for (const k of ['type', 'status', 'pid', 'starttime', 'user', 'node', 'endtime', 'exitstatus']) {
if (st && st[k] !== undefined) lines.push(`${k}: ${st[k]}`);
}
log.textContent = lines.join('\n') || '—';
}, async (finalSt) => {
stopSig.done = true;
const exit = (finalSt && finalSt.exitstatus) ? String(finalSt.exitstatus) : '';
const ok = exit.toUpperCase() === 'OK';
const badgeCell = tr.children[4];
if (badgeCell) badgeCell.innerHTML = ok ? badge('running', 'ok') : badge('migrate error', 'err');
log.textContent += (log.textContent ? '\n' : '') + (ok ? 'Migration finished successfully.' : ('Migration failed: ' + (exit || 'unknown error')));
setRowBusy(tr, false);
await doRefresh();
// auto-collapse on success after 2s
if (ok) { setTimeout(() => { const row = tr.nextElementSibling; if (row && row.classList.contains('mig-row')) setMigRowVisible(row, false); }, 2000); }
});
} else {
await vmAction(sid, action, needsTarget ? getTarget() : undefined);
const exit = (finalSt && finalSt.exitstatus) ? String(finalSt.exitstatus) : '';
const ok = exit.toUpperCase() === 'OK';
const badgeCell = tr.children[4];
if (badgeCell) badgeCell.innerHTML = ok ? badge('running', 'ok') : badge('migrate error', 'err');
log.textContent += (log.textContent ? '\n' : '') + (ok ? 'Migration finished successfully.' : ('Migration failed: ' + (exit || 'unknown error')));
await doRefresh();
}
} catch (e) { showToast('Error', 'ERROR: ' + e.message, 'danger'); }
btn.disabled = false;
};
};
bind('.act-unlock', 'unlock');
bind('.act-start', 'start');
bind('.act-stop', 'stop');
bind('.act-shutdown', 'shutdown');
bind('.act-migrate', 'migrate', true);
const statusBtn = tr.querySelector('.act-status');
if (statusBtn) {
statusBtn.onclick = () => {
const row = ensureMigRow(tr, colSpan);
setMigRowVisible(row, row.classList.contains('d-none'));
};
}
});
}
async function pollTask(upid, node, onUpdate, onDone) {
let lastSt = null;
let lastSt = null;
if (!upid || !node) return;
const started = Date.now();
const maxMs = 5 * 60 * 1000;
const delay = (ms) => new Promise(r => setTimeout(r, ms));
while (Date.now() - started < maxMs) {
try {
const r = await fetch('/api/task-status?upid=' + encodeURIComponent(upid) + '&node=' + encodeURIComponent(node));
const d = await r.json();
if (d && d.ok) {
const st = d.status || {}; lastSt = st; lastSt = st; if (onUpdate) { try { onUpdate(st); } catch { } }
const s = (st.status || '').toLowerCase();
if (s === 'stopped' || st.exitstatus) break;
}
} catch (e) { }
await delay(2000);
}
try { if (onDone) onDone(lastSt); } catch { }
}
function ensureMigRow(mainTr, colSpan) {
let nxt = mainTr.nextElementSibling;
if (!nxt || !nxt.classList.contains('mig-row')) {
nxt = document.createElement('tr');
nxt.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">Migration status</div><pre class="small mb-0 mig-log">—</pre></div>';
nxt.appendChild(td);
mainTr.parentNode.insertBefore(nxt, mainTr.nextSibling);
}
return nxt;
}
function setMigRowVisible(row, vis) {
row.classList.toggle('d-none', !vis);
}
function setRowBusy(tr, busy) {
const nameCell = tr.children[2];
if (!nameCell) return;
let spin = nameCell.querySelector('.op-spin');
if (busy) {
if (!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);
}
} else {
if (spin) spin.remove();
}
}
function showToast(title, body, variant) {
const cont = document.getElementById('toast-container');
if (!cont) { console.warn('toast container missing'); return; }
const id = 't' + Math.random().toString(36).slice(2);
const vcls = {
success: 'text-bg-success',
info: 'text-bg-info',
warning: 'text-bg-warning',
danger: 'text-bg-danger',
secondary: 'text-bg-secondary'
}[variant || 'secondary'];
const el = document.createElement('div');
el.className = 'toast align-items-center ' + vcls;
el.setAttribute('role', 'alert');
el.setAttribute('aria-live', 'assertive');
el.setAttribute('aria-atomic', 'true');
el.innerHTML = `
<div class="d-flex">
<div class="toast-body">
<strong>${title || ''}</strong>${title ? ': ' : ''}${body || ''}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>`;
cont.appendChild(el);
const t = new bootstrap.Toast(el, { delay: 5000 });
t.show();
el.addEventListener('hidden.bs.toast', () => el.remove());
}
async function tailTaskLog(upid, node, preEl, stopSignal) {
let start = 0;
const delay = (ms) => new Promise(r => setTimeout(r, ms));
const append = (txt) => {
if (!preEl) return;
preEl.textContent = (preEl.textContent ? preEl.textContent + '\n' : '') + txt;
trimLogEl(preEl);
preEl.scrollTop = preEl.scrollHeight;
};
while (!stopSignal.done) {
try {
const r = await fetch('/api/task-log?upid=' + encodeURIComponent(upid) + '&node=' + encodeURIComponent(node) + '&start=' + start);
const d = await r.json();
if (d && d.ok) {
const lines = Array.isArray(d.lines) ? d.lines : [];
for (const ln of lines) {
if (ln && typeof ln.t === 'string') append(ln.t);
}
start = d.next_start ?? start;
}
} catch (e) {
// transient errors ignored
}
await delay(1500);
}
}
const LOG_MAX_CHARS = 64 * 1024; // 64KB cap for log panel
function trimLogEl(preEl) {
if (!preEl) return;
const txt = preEl.textContent || '';
if (txt.length > LOG_MAX_CHARS) {
preEl.textContent = txt.slice(-LOG_MAX_CHARS);
}
}