// ------ helpers ------ 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 `${safe(txt)}`; } function rowHTML(cols, attrs = '') { return `${cols.map(c => `${c ?? '—'}`).join('')}`; } 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 `
${keys.map(k => `
${titleMap[k] || k}
${safe(obj[k])}
`).join('')}
`; } // 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; } } 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 = `
${netRows.length ? netRows.join('') : rowHTML(['—', '—', '—', '—', '—', '—'])}
IFModelBridgeVLANMACFW
`; const agentSummary = (agInfo || agOS || guestIPs.length) ? `
${agOS ? `
Guest OS: ${safe(guestName)}
` : ''} ${guestIPs.length ? `
Guest IPs: ${guestIPs.map(ip => badge(ip, 'info')).join(' ')}
` : ''} ${agInfo ? `
Agent: ${badge('present', 'ok')}
` : `
Agent: ${badge('not available', 'err')}
`}
` : '
No guest agent data
'; 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 = ` `; const rawBox = `
${JSON.stringify(cur, null, 2)}
${JSON.stringify(cfg, null, 2)}
${JSON.stringify(ag, null, 2)}
`; return `
${safe(meta.name || cfg.name || d.sid)}
${(d.type || '').toUpperCase()} / VMID ${safe(d.vmid)} @ ${safe(d.node)}
${statusBadge}
${meta.hastate ? `
HA: ${badge(meta.hastate, /started/i.test(meta.hastate) ? 'ok' : 'warn')}
` : ''} ${ha.state ? `
HA runtime: ${haBadge}
` : ''}
CPU
${cur.cpu !== undefined ? (cur.cpu * 100).toFixed(1) + '%' : '—'}
vCPUs: ${safe(cur.cpus ?? cfg.cores ?? cfg.sockets)}
Memory (used/free/total)
${(used != null && maxmem != null) ? `${humanBytes(used)} / ${humanBytes(free)} / ${humanBytes(maxmem)}` : '—'}
Ballooning: ${balloonEnabled ? badge('enabled', 'ok') : badge('disabled', 'err')}
${binfo ? `
Balloon actual: ${humanBytes(binfo.actual)} | guest free: ${humanBytes(binfo.free_mem)} | guest total: ${humanBytes(binfo.total_mem)}
` : ''}
Disk (used/total)
${(cur.disk != null && cur.maxdisk != null) ? `${humanBytes(cur.disk)} / ${humanBytes(cur.maxdisk)}` : '—'}
R: ${humanBytes(cur.diskread || 0)} | W: ${humanBytes(cur.diskwrite || 0)}
Uptime
${fmtSeconds(cur.uptime)}
Tags: ${safe(cfg.tags || meta.tags || '—')}
Network (config)
${netTable}
Disks (block statistics)
${Object.keys(bstat).length ? bRows.join('') : rowHTML(['—', '—', '—', '—', '—', '—', '—'])}
DeviceRead bytesRead opsWrite bytesWrite opsFlush opsHighest offset
System / QEMU
${kvGrid(sysCards, Object.keys(sysCards))}
Config facts
${kvGrid(cfgFacts, Object.keys(cfgFacts))}
${agentSummary}
${rawBtn} ${rawBox} `; } // ------ Node detail card ------ function renderNodeDetailCard(d) { const st = d.status || {}; const ver = d.version || {}; const tm = d.time || {}; const netcfg = ensureArr(d.network_cfg); const disks = ensureArr(d.disks); const sub = d.subscription || {}; // 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 (new content) 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 = `
${safe(d.node)}
${sB}
CPU: ${pct(st.cpu)}
Load: ${safe(load)}
Uptime: ${fmtSeconds(st.uptime)}
`; const memCard = `
Memory
${(mem.used != null && mem.total != null) ? `${humanBytes(mem.used)} / ${humanBytes(mem.total)} (${pct(mem.used / mem.total)})` : '—'}
RootFS
${(root.used != null && root.total != null) ? `${humanBytes(root.used)} / ${humanBytes(root.total)} (${pct(root.used / root.total)})` : '—'}
Kernel / QEMU
${safe(tech['Kernel'])} / ${safe(pick(ver.qemu, ver['running-qemu']))}
Time
${safe(tm.localtime)} ${tm.timezone ? `(${tm.timezone})` : ''}
`; // --- SYSTEM DETAILS GRID (requested fields) 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 = ensureArr(d.network_cfg).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 = `
${netRows.length ? netRows.join('') : rowHTML(['—', '—', '—', '—', '—', '—'])}
IFTypeMethodPorts/AddressNetmask/CIDRComment
`; // Disks const diskRows = ensureArr(d.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 = `
${diskRows.length ? diskRows.join('') : rowHTML(['—', '—', '—', '—', '—'])}
DeviceModelSizeHealthSerial
`; // Subscription const sub = d.subscription || {}; const subBox = `
Status: ${badge(safe(sub.status || 'unknown'), /active|valid/i.test(sub.status || '') ? 'ok' : 'warn')}
${sub.productname ? `
Product: ${safe(sub.productname)}
` : ''} ${sub.message ? `
${safe(sub.message)}
` : ''}
`; // Collapsible raw JSON const rawId = `raw-node-${safe(d.node)}`; const rawBtn = ` `; const rawBox = `
${JSON.stringify(d, null, 2)}
`; return ` ${top} ${memCard}
System details
${sysDetails}
Network (config)
${netCfgTable}
Disks
${diskTable}
Subscription
${subBox}
${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', `
${safe(cluster.name)}
id: ${safe(cluster.id)}
${qB}
nodes: ${safe(cluster.nodes)}
version: ${safe(cluster.version)}
`); 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', `
${rows.join('')}
NodeStatusIPNodeIDLevel
`); } 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', `${badge(k, map[v] || 'dark')}`)); } 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 = 'No replication jobs'); 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 = `
${rows.join('')}
JobIDEnabledTargetLastSyncNextSyncDurationFailCountState
`; } // 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(`
`); }); 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(`
`); }); 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 = ` ${safe(n.node)} ${sB}${cpu}${load}${mem}${rfs}${fmtSeconds(n.uptime)} `; const detail = `
`; 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);