// ------ 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${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('')}
`; } // ------ 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 tbody'), tblHaStatus=$('#ha-status tbody'), tblNodes=$('#nodes tbody'), tblNonHA=$('#nonha tbody'); 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; const nets = parseVmNetworks(cfg); 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 nicStats = cur.nics || {}; const nicRows = Object.keys(nicStats).sort().map(ifn=>{ const ns = nicStats[ifn]||{}; return rowHTML([ifn, humanBytes(ns.netin||0), humanBytes(ns.netout||0)]); }); 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 head = `
${meta.name || cfg.name || d.sid}
${(d.type||'').toUpperCase()} / VMID ${d.vmid} @ ${d.node}
${statusBadge}
${meta.hastate ? `
HA: ${badge(meta.hastate, /started/i.test(meta.hastate)?'ok':'warn')}
` : ''} ${ha.state ? `
HA runtime: ${haBadge}
` : ''}
`; const quick = `
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||'—')}
`; const netRows = (parseVmNetworks(cfg)).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 nicLive = `
${nicRows.length?nicRows.join(''):rowHTML(['—','—','—'])}
TAPRXTX
`; const blkTable = `
${bRows.length?bRows.join(''):rowHTML(['—','—','—','—','—','—','—'])}
DeviceRead bytesRead opsWrite bytesWrite opsFlush opsHighest offset
`; 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 ` ${head} ${quick}
Network (config)
${netTable}
Network (live)
${nicLive}
Disks (block statistics)
${blkTable}
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 svcs = ensureArr(d.services); const netcfg = ensureArr(d.network_cfg); const netstat = ensureArr(d.netstat); 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 = st.loadavg || ''; 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(ver.kernel || (ver['release']||''))} / ${safe(ver.qemu||ver['running-qemu']||'')}
Time
${safe(tm.localtime)} ${tm.timezone?`(${tm.timezone})`:''}
`; // --- NORMALIZE service names + build badge const svcMap = {}; svcs.forEach(s => { const raw = (s && s.name) ? s.name : ''; const key = raw.replace(/\.service$/,''); // normalize const st = (s && (s.state || s.active)) || ''; svcMap[key] = st; }); function svcBadge(name){ const st = String(svcMap[name]||'').toLowerCase(); return (/running|active/.test(st)) ? badge('active','ok') : (st ? badge(st,'err') : badge('inactive','err')); } const svcTable = `
ServiceStateServiceStateServiceState
watchdog-mux${svcBadge('watchdog-mux')} pve-ha-crm${svcBadge('pve-ha-crm')} pve-ha-lrm${svcBadge('pve-ha-lrm')}
`; // 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 = `
${netRows.length?netRows.join(''):rowHTML(['—','—','—','—','—','—'])}
IFTypeMethodPorts/AddressNetmask/CIDRComment
`; // Netstat live const nsRows = netstat.map(x=> rowHTML([safe(x.dev||x.iface), humanBytes(x.rx_bytes||x['receive-bytes']||0), humanBytes(x.tx_bytes||x['transmit-bytes']||0)])); const netLiveTable = `
${nsRows.length?nsRows.join(''):rowHTML(['—','—','—'])}
IFRXTX
`; // 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 = `
${diskRows.length?diskRows.join(''):rowHTML(['—','—','—','—','—'])}
DeviceModelSizeHealthSerial
`; // 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}
HA services
${svcTable}
Network (config)
${netCfgTable}
Network (live)
${netLiveTable}
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.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 tbody 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 tbody 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 tbody 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 tbody 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}${safe(n.uptime)} `; const detail = `
`; return main + detail; }); setRows(tblNodes, nrows); Array.from(document.querySelectorAll('#nodes tbody 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 tbody 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);