This commit is contained in:
Mateusz Gruszczyński
2025-10-17 14:26:17 +02:00
parent 9a21739eb9
commit 38e5fc6e49
5 changed files with 664 additions and 489 deletions

140
README.md
View File

@@ -1,20 +1,42 @@
# 1) katalog + venv
sudo mkdir -p /opt/pve-ha-web
sudo chown -R $USER:$USER /opt/pve-ha-web
# PVE HA Web Panel — Deployment Guide
This guide explains how to deploy **PVE HA Web Panel** on a Proxmox host or any Debian/Ubuntu-like system.
---
## 1) Create directory
```bash
mkdir -p /opt/pve-ha-web
cd /opt/pve-ha-web
```
# 2) pliki aplikacji (app.py, templates/, static/, requirements.txt) — skopiuj tu
# …gdy już je masz w katalogu…
## 2) Get the application
# 3) virtualenv + deps
Clone the repository (includes `app.py`, `templates/`, `static/`, `requirements.txt`).
```bash
git clone https://gitea.linuxiarz.pl/gru/pve-ha-web.git .
```
> **Note:** The trailing `.` clones directly into `/opt/pve-ha-web` instead of a nested subfolder.
## 3) Python virtualenv & dependencies
```bash
python3 -m venv venv
source venv/bin/activate
python -m pip install --upgrade pip
pip install -r requirements.txt
deactivate
```
# 4) systemd unit
sudo tee /etc/systemd/system/pve-ha-web.service >/dev/null <<'UNIT'
## 4) systemd unit
Create a service unit file for Gunicorn:
```bash
tee /etc/systemd/system/pve-ha-web.service >/dev/null <<'UNIT'
[Unit]
Description=PVE HA Web Panel
After=network.target
@@ -23,7 +45,7 @@ After=network.target
Type=simple
WorkingDirectory=/opt/pve-ha-web
Environment="PYTHONUNBUFFERED=1"
ExecStart=/opt/pve-ha-web/venv/bin/gunicorn -w 2 -b 0.0.0.0:8000 app:app
ExecStart=/opt/pve-ha-web/venv/bin/gunicorn -w 2 -b 0.0.0.0:8007 app:app
Restart=on-failure
RestartSec=3
User=root
@@ -32,11 +54,101 @@ Group=root
[Install]
WantedBy=multi-user.target
UNIT
```
# 5) start + autostart
sudo systemctl daemon-reload
sudo systemctl enable --now pve-ha-web
> **Port:** The app listens on `8007` by default. Adjust as needed.
# 6) sprawdzenie
## 5) Enable & start
```bash
systemctl daemon-reload
systemctl enable --now pve-ha-web
```
## 6) Verify
```bash
systemctl status pve-ha-web
ss -ltnp | grep :8000
ss -ltnp | grep :8007
```
Open the app in the browser: `http://<server-ip>:8007`
---
## Optional: Reverse proxy (Nginx)
If you want to expose it under standard HTTPS with a domain:
```nginx
server {
listen 80;
server_name ha.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name ha.example.com;
# TLS certs (replace with your paths / cert manager)
ssl_certificate /etc/letsencrypt/live/ha.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ha.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8007;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Reload Nginx: `sudo systemctl reload nginx`
---
## Management
### Logs
```bash
journalctl -u pve-ha-web -e -f
```
### Restart / stop
```bash
sudo systemctl restart pve-ha-web
sudo systemctl stop pve-ha-web
```
### Update to latest version
```bash
cd /opt/pve-ha-web
git pull --rebase
source venv/bin/activate
pip install -r requirements.txt
deactivate
sudo systemctl restart pve-ha-web
```
---
## Uninstall
```bash
sudo systemctl disable --now pve-ha-web
sudo rm -f /etc/systemd/system/pve-ha-web.service
sudo systemctl daemon-reload
sudo rm -rf /opt/pve-ha-web
```
---
## Project
- Author: **linuxiarz.pl**
- Source: <https://gitea.linuxiarz.pl/gru/pve-ha-web>

Binary file not shown.

6
app.py
View File

@@ -291,10 +291,6 @@ def node_ha_services(node: str) -> Dict[str, str]:
return {"crm_state": one("pve-ha-crm"), "lrm_state": one("pve-ha-lrm")}
def units_for_node(node: str) -> Dict[str, str]:
"""
Preferuj /nodes/<node>/services. Normalizuj nazwy (bez .service)
i mapuj różne pola na 'active'/'inactive'.
"""
wanted = {"watchdog-mux", "pve-ha-crm", "pve-ha-lrm"}
svc = get_json(["pvesh", "get", f"/nodes/{node}/services"]) or []
states: Dict[str, str] = {}
@@ -438,7 +434,7 @@ def api_disable():
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser()
p.add_argument("--bind", default="127.0.0.1:8088", help="addr:port")
p.add_argument("--bind", default="127.0.0.1:8007", help="addr:port")
p.add_argument("--node", default=DEFAULT_NODE, help="default node")
args = p.parse_args()
DEFAULT_NODE = args.node

View File

@@ -1,172 +1,184 @@
// ------ 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'];
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 '—';
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;
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();
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||{})){
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)}); }
if (m) { nets.push({ idx: +m[1], raw: v, ...parseNetConf(v) }); }
}
nets.sort((a,b)=>a.idx-b.idx);
nets.sort((a, b) => a.idx - b.idx);
return nets;
}
function kvGrid(obj, keys, titleMap={}){
function kvGrid(obj, keys, titleMap = {}) {
return `<div class="row row-cols-1 row-cols-md-2 g-2">
${keys.map(k=>`
${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="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 tbody'), tblHaStatus=$('#ha-status tbody'), tblNodes=$('#nodes tbody'), tblNonHA=$('#nonha tbody');
const pvecmPre=$('#pvecm'), cfgtoolPre=$('#cfgtool'), footer=$('#footer');
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){
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')));
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();
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||''));
async function fetchSnapshot() {
const r = await fetch('/api/info?node=' + encodeURIComponent(nodeInput.value || ''));
return await r.json();
}
async function doRefresh(){
async function doRefresh() {
const d = await fetchSnapshot();
renderSnap(d);
if (!doRefresh.didNonHA){ await renderNonHA(); doRefresh.didNonHA=true; }
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.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);
} else {
const iv = parseInt(selInterval.value || '30000', 10);
REF_TIMER = setInterval(doRefresh, iv);
btnAuto.textContent='ON';
btnAuto.textContent = 'ON';
btnAuto.classList.remove('btn-outline-success'); btnAuto.classList.add('btn-success');
selInterval.disabled = false;
}
};
selInterval.onchange = ()=>{
if(REF_TIMER){
selInterval.onchange = () => {
if (REF_TIMER) {
clearInterval(REF_TIMER);
REF_TIMER = setInterval(doRefresh, parseInt(selInterval.value||'30000',10));
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));
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));
async function fetchNodeDetail(name) {
const r = await fetch('/api/node?name=' + encodeURIComponent(name));
return await r.json();
}
// ------ Normalizacja stanu usług ------
function normSvcState(s) {
const raw = String(pick(
s.active, s['active-state'], s.ActiveState, s.activestate,
s.state, s.SubState, s.substate
)).toLowerCase();
const bad = String(pick(s.result, s.Result, s['exit-code'])).toLowerCase();
if (/failed|error|dead/.test(bad)) return 'failed';
if (/^active$|running/.test(raw)) return 'active';
if (/activating|starting/.test(raw)) return 'activating';
if (/deactivating|stopping/.test(raw)) return 'deactivating';
if (/failed|dead/.test(raw)) return 'failed';
return 'inactive';
}
// ------ 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;
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 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);
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=>{
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]||{};
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)
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 haBadge = ha.state ? (/started/i.test(ha.state) ? badge(ha.state, 'ok') : badge(ha.state, 'warn')) : badge('—', 'dark');
const sysCards = {
'QMP status': cur.qmpstatus,
@@ -178,80 +190,28 @@ function renderVmDetailCard(d){
'Pressure MEM (some/full)': `${safe(cur.pressurememorysome)}/${safe(cur.pressurememoryfull)}`
};
const head = `
<div class="d-flex flex-wrap align-items-center gap-3 mb-2">
<div class="fw-bold">${meta.name || cfg.name || d.sid}</div>
<div class="text-muted small">${(d.type||'').toUpperCase()} / VMID ${d.vmid} @ ${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>`;
const quick = `
<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>`;
const netRows = (parseVmNetworks(cfg)).map(n=>{
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');
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>
<tbody>${netRows.length ? netRows.join('') : rowHTML(['—', '—', '—', '—', '—', '—'])}</tbody>
</table>
</div>`;
const nicLive = `
<div class="table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap">
<thead><tr><th>TAP</th><th>RX</th><th>TX</th></tr></thead>
<tbody>${nicRows.length?nicRows.join(''):rowHTML(['—','—','—'])}</tbody>
</table>
</div>`;
const blkTable = `
<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>${bRows.length?bRows.join(''):rowHTML(['—','—','—','—','—','—','—'])}</tbody>
</table>
</div>`;
const agentSummary = (agInfo||agOS||guestIPs.length)
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>`}
${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>';
@@ -277,25 +237,59 @@ function renderVmDetailCard(d){
</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="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 `
${head}
${quick}
<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 class="fw-semibold mt-3 mb-1">Network (live)</div>
${nicLive}
</div>
<div class="mt-3">
<div class="fw-semibold mb-1">Disks (block statistics)</div>
${blkTable}
<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">
@@ -313,24 +307,23 @@ function renderVmDetailCard(d){
}
// ------ Node detail card ------
function renderNodeDetailCard(d){
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 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 || {};
@@ -351,38 +344,37 @@ function renderNodeDetailCard(d){
<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 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 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(ver.kernel || (ver['release']||''))} / ${safe(ver.qemu||ver['running-qemu']||'')}</div>
<div class="fw-semibold">${safe(ver.kernel || (ver['release'] || ''))} / ${safe(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 class="fw-semibold">${safe(tm.localtime)} ${tm.timezone ? `(${tm.timezone})` : ''}</div>
</div></div></div>
</div>`;
// --- NORMALIZE service names + build badge
// --- NORMALIZE service names + 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;
const key = raw.replace(/\.service$/, ''); // normalize
svcMap[key] = normSvcState(s);
});
function svcBadge(name){
const st = String(svcMap[name]||'').toLowerCase();
return (/running|active/.test(st)) ? badge('active','ok') :
(st ? badge(st,'err') : badge('inactive','err'));
function svcBadge(name) {
const st = String(svcMap[name] || '').toLowerCase();
if (st === 'active') return badge('active', 'ok');
if (st === 'activating' || st === 'deactivating') return badge(st, 'warn');
if (st === 'failed') return badge('failed', 'err');
return badge('inactive', 'err');
}
const svcTable = `
<div class="table-responsive">
@@ -399,43 +391,33 @@ function renderNodeDetailCard(d){
</div>`;
// 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 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>`;
// 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 = `
<div class="table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap">
<thead><tr><th>IF</th><th>RX</th><th>TX</th></tr></thead>
<tbody>${nsRows.length?nsRows.join(''):rowHTML(['—','—','—'])}</tbody>
<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 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>
<tbody>${diskRows.length ? diskRows.join('') : rowHTML(['—', '—', '—', '—', '—'])}</tbody>
</table>
</div>`;
// Subscription
const subBox = `
<div class="small">
<div>Status: ${badge(safe(sub.status||'unknown'), /active|valid/i.test(sub.status||'')?'ok':'warn')}</div>
${sub.productname?`<div>Product: <strong>${safe(sub.productname)}</strong></div>`:''}
${sub.message?`<div class="text-muted">${safe(sub.message)}</div>`:''}
<div>Status: ${badge(safe(sub.status || 'unknown'), /active|valid/i.test(sub.status || '') ? 'ok' : 'warn')}</div>
${sub.productname ? `<div>Product: <strong>${safe(sub.productname)}</strong></div>` : ''}
${sub.message ? `<div class="text-muted">${safe(sub.message)}</div>` : ''}
</div>`;
// Collapsible raw JSON
@@ -462,8 +444,6 @@ function renderNodeDetailCard(d){
<div class="mt-3">
<div class="fw-semibold mb-1">Network (config)</div>
${netCfgTable}
<div class="fw-semibold mt-3 mb-1">Network (live)</div>
${netLiveTable}
</div>
<div class="mt-3">
@@ -482,18 +462,18 @@ function renderNodeDetailCard(d){
}
// ------ Sections ------
function setHealth(ok, vq, unitsActive){
healthDot.classList.toggle('ok',!!ok);
healthDot.classList.toggle('bad',!ok);
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)}`;
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');
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">
@@ -504,11 +484,11 @@ function renderClusterCards(arr){
<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)]);
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">
@@ -519,102 +499,102 @@ function renderClusterCards(arr){
</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 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]});
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='<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]);
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">
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'));
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(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 tbody tr.vm-row')).forEach((tr,i)=>{
tr.onclick = async ()=>{
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 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){
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; }
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');
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(){
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}`+"\""));
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 tbody tr.vm-row')).forEach((tr,i)=>{
tr.onclick = async ()=>{
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 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){
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; }
catch (e) { content.textContent = 'ERROR: ' + e; }
spin.classList.add('d-none');
}
};
@@ -622,24 +602,24 @@ async function renderNonHA(){
}
// 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');
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 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>${safe(n.uptime)}</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">
@@ -653,18 +633,18 @@ function renderNodesTable(nodes){
});
setRows(tblNodes, nrows);
Array.from(document.querySelectorAll('#nodes tbody tr.node-row')).forEach((tr,i)=>{
tr.onclick = async ()=>{
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 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){
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; }
catch (e) { content.textContent = 'ERROR: ' + e; }
spin.classList.add('d-none');
}
};
@@ -672,9 +652,9 @@ function renderNodesTable(nodes){
}
// ------ 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;
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();
@@ -690,7 +670,7 @@ function renderSnap(d){
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()}`;
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)

View File

@@ -1,206 +1,293 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PVE HA Panel</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="{{ url_for('static', filename='styles.css') }}" rel="stylesheet">
<style>
footer.site-footer {
border-top: 1px solid rgba(255, 255, 255, .1);
}
footer.site-footer a {
text-decoration: none;
}
footer.site-footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container py-4">
<div class="container py-4">
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
<h1 class="h4 m-0">PVE HA Panel</h1>
<div class="d-flex flex-wrap align-items-center gap-2">
<input class="form-control form-control-sm" id="node" value="{{ node }}" style="width: 180px">
<button class="btn btn-outline-secondary btn-sm" id="btnToggleAll">Collapse/Expand all</button>
<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 gap-2">
<h1 class="h4 m-0">PVE HA Panel</h1>
<div class="d-flex flex-wrap align-items-center gap-2">
<input class="form-control form-control-sm" id="node" value="{{ node }}" style="width: 180px">
<button class="btn btn-outline-secondary btn-sm" id="btnToggleAll">Collapse/Expand all</button>
<div class="vr d-none d-md-block"></div>
<div class="vr d-none d-md-block"></div>
<button class="btn btn-primary btn-sm" id="btnRefresh">Refresh now</button>
<div class="input-group input-group-sm" style="width:220px">
<span class="input-group-text">Auto-refresh</span>
<select class="form-select" id="selInterval" disabled>
<option value="10000">10 s</option>
<option value="20000">20 s</option>
<option value="30000" selected>30 s</option>
<option value="45000">45 s</option>
<option value="60000">60 s</option>
<option value="120000">120 s</option>
</select>
<button class="btn btn-outline-success" id="btnAuto">OFF</button>
</div>
<button class="btn btn-primary btn-sm" id="btnRefresh">Refresh now</button>
<div class="input-group input-group-sm" style="width:220px">
<span class="input-group-text">Auto-refresh</span>
<select class="form-select" id="selInterval" disabled>
<option value="10000">10 s</option>
<option value="20000">20 s</option>
<option value="30000" selected>30 s</option>
<option value="45000">45 s</option>
<option value="60000">60 s</option>
<option value="120000">120 s</option>
</select>
<button class="btn btn-outline-success" id="btnAuto">OFF</button>
</div>
<div class="vr d-none d-md-block"></div>
<div class="vr d-none d-md-block"></div>
<button type="button" class="btn btn-success btn-sm" id="btnEnable">Enable maintenance</button>
<button type="button" class="btn btn-danger btn-sm" id="btnDisable">Disable maintenance</button>
</div>
</div>
<!-- Global loading -->
<div id="global-loading" class="d-flex align-items-center gap-2 mb-3">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span class="small text-muted">Loading data…</span>
</div>
<!-- HEALTH -->
<div class="card mb-3 border-0 shadow health-card">
<div class="card-body d-flex flex-wrap align-items-center gap-3">
<div class="health-dot" id="healthDot"></div>
<div>
<div class="fw-bold" id="healthTitle">Loading…</div>
<div class="text-muted small" id="healthSub"></div>
<button type="button" class="btn btn-success btn-sm" id="btnEnable">Enable maintenance</button>
<button type="button" class="btn btn-danger btn-sm" id="btnDisable">Disable maintenance</button>
</div>
</div>
</div>
<ul class="nav nav-tabs mb-3" id="mainTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-ha" type="button">HA</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-nonha" type="button">VM/CT (non-HA)</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-nodes" type="button">Nodes</button>
</li>
</ul>
<!-- Global loading -->
<div id="global-loading" class="d-flex align-items-center gap-2 mb-3">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span class="small text-muted">Loading data…</span>
</div>
<div class="tab-content">
<!-- TAB: HA -->
<div class="tab-pane fade show active" id="tab-ha">
<div class="accordion" id="acc">
<!-- Cluster / Quorum -->
<div class="accordion-item">
<h2 class="accordion-header" id="h-q">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-q">
Cluster / Quorum
</button>
</h2>
<div id="c-q" class="accordion-collapse collapse" data-bs-parent="#acc">
<div class="accordion-body">
<div id="q-summary" class="mb-3 small">Loading…</div>
<div class="row g-3" id="q-cards"><div class="text-muted small">Loading…</div></div>
</div>
</div>
<!-- HEALTH -->
<div class="card mb-3 border-0 shadow health-card">
<div class="card-body d-flex flex-wrap align-items-center gap-3">
<div class="health-dot" id="healthDot"></div>
<div>
<div class="fw-bold" id="healthTitle">Loading…</div>
<div class="text-muted small" id="healthSub"></div>
</div>
</div>
</div>
<!-- Systemd -->
<div class="accordion-item">
<h2 class="accordion-header" id="h-sd">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-sd">
Systemd (HA)
</button>
</h2>
<div id="c-sd" class="accordion-collapse collapse" data-bs-parent="#acc">
<div class="accordion-body">
<div id="units" class="d-flex flex-wrap gap-2"><span class="text-muted small">Loading…</span></div>
</div>
</div>
</div>
<ul class="nav nav-tabs mb-3" id="mainTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-ha" type="button">HA</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-nonha" type="button">VM/CT (non-HA)</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-nodes" type="button">Nodes</button>
</li>
</ul>
<!-- Replication -->
<div class="accordion-item">
<h2 class="accordion-header" id="h-repl">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-repl">
Replication (pvesr)
</button>
</h2>
<div id="c-repl" class="accordion-collapse collapse" data-bs-parent="#acc">
<div class="accordion-body"><div id="repl"><span class="text-muted small">Loading…</span></div></div>
</div>
</div>
<div class="tab-content">
<!-- TAB: HA -->
<div class="tab-pane fade show active" id="tab-ha">
<div class="accordion" id="acc">
<!-- HA resources -->
<div class="accordion-item">
<h2 class="accordion-header" id="h-res">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-res">
HA — resources
</button>
</h2>
<div id="c-res" class="accordion-collapse collapse" data-bs-parent="#acc">
<div class="accordion-body table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap" id="ha-res">
<thead><tr><th>SID</th><th>State</th><th>Node</th><th>Group</th><th>Flags/Comment</th></tr></thead>
<tbody></tbody>
</table>
<div class="small text-muted">Click a row to expand VM/CT details.</div>
</div>
</div>
</div>
<!-- HA status -->
<div class="accordion-item">
<h2 class="accordion-header" id="h-hastatus">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-hastatus">
HA — status
</button>
</h2>
<div id="c-hastatus" class="accordion-collapse collapse" data-bs-parent="#acc">
<div class="accordion-body table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap" id="ha-status">
<thead><tr><th>Node</th><th>Status</th><th>CRM</th><th>LRM</th></tr></thead>
<tbody><tr><td colspan="4" class="text-muted">Loading…</td></tr></tbody>
</table>
</div>
</div>
</div>
<!-- Raw -->
<div class="accordion-item">
<h2 class="accordion-header" id="h-raw">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-raw">
Raw: pvecm / cfgtool
</button>
</h2>
<div id="c-raw" class="accordion-collapse collapse" data-bs-parent="#acc">
<div class="accordion-body">
<div class="row g-3">
<div class="col-lg-6"><pre id="pvecm" class="mb-0 small"></pre></div>
<div class="col-lg-6"><pre id="cfgtool" class="mb-0 small"></pre></div>
<!-- Cluster / Quorum -->
<div class="accordion-item">
<h2 class="accordion-header" id="h-q">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-q">
Cluster / Quorum
</button>
</h2>
<div id="c-q" class="accordion-collapse collapse" data-bs-parent="#acc">
<div class="accordion-body">
<div id="q-summary" class="mb-3 small">Loading…</div>
<div class="row g-3" id="q-cards">
<div class="text-muted small">Loading…</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Systemd -->
<div class="accordion-item">
<h2 class="accordion-header" id="h-sd">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#c-sd">
Systemd (HA)
</button>
</h2>
<div id="c-sd" class="accordion-collapse collapse" data-bs-parent="#acc">
<div class="accordion-body">
<div id="units" class="d-flex flex-wrap gap-2"><span class="text-muted small">Loading…</span></div>
</div>
</div>
</div>
<!-- Replication -->
<div class="accordion-item">
<h2 class="accordion-header" id="h-repl">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#c-repl">
Replication (pvesr)
</button>
</h2>
<div id="c-repl" class="accordion-collapse collapse" data-bs-parent="#acc">
<div class="accordion-body">
<div id="repl"><span class="text-muted small">Loading…</span></div>
</div>
</div>
</div>
<!-- HA resources -->
<div class="accordion-item">
<h2 class="accordion-header" id="h-res">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#c-res">
HA — resources
</button>
</h2>
<div id="c-res" class="accordion-collapse collapse" data-bs-parent="#acc">
<div class="accordion-body table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap" id="ha-res">
<thead>
<tr>
<th>SID</th>
<th>State</th>
<th>Node</th>
<th>Group</th>
<th>Flags/Comment</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="small text-muted">Click a row to expand VM/CT details.</div>
</div>
</div>
</div>
<!-- HA status -->
<div class="accordion-item">
<h2 class="accordion-header" id="h-hastatus">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#c-hastatus">
HA — status
</button>
</h2>
<div id="c-hastatus" class="accordion-collapse collapse" data-bs-parent="#acc">
<div class="accordion-body table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap" id="ha-status">
<thead>
<tr>
<th>Node</th>
<th>Status</th>
<th>CRM</th>
<th>LRM</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="4" class="text-muted">Loading…</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Raw -->
<div class="accordion-item">
<h2 class="accordion-header" id="h-raw">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#c-raw">
Raw: pvecm / cfgtool
</button>
</h2>
<div id="c-raw" class="accordion-collapse collapse" data-bs-parent="#acc">
<div class="accordion-body">
<div class="row g-3">
<div class="col-lg-6">
<pre id="pvecm" class="mb-0 small"></pre>
</div>
<div class="col-lg-6">
<pre id="cfgtool" class="mb-0 small"></pre>
</div>
</div>
</div>
</div>
</div>
<!-- TAB: Non-HA -->
<div class="tab-pane fade" id="tab-nonha">
<div class="card border-0">
<div class="card-body table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap" id="nonha">
<thead><tr><th>SID</th><th>Type</th><th>Name</th><th>Node</th><th>Status</th></tr></thead>
<tbody><tr><td colspan="5" class="text-muted">Loading…</td></tr></tbody>
</table>
<div class="small text-muted">Click a row to expand VM/CT details.</div>
</div>
</div>
</div>
<!-- TAB: Nodes (expandable) -->
<div class="tab-pane fade" id="tab-nodes">
<div class="card border-0">
<div class="card-body table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap" id="nodes">
<thead><tr><th>Node</th><th>Status</th><th>CPU</th><th>Load</th><th>Mem</th><th>RootFS</th><th>Uptime</th></tr></thead>
<tbody><tr><td colspan="7" class="text-muted">Loading…</td></tr></tbody>
</table>
<div class="small text-muted">Click a row to expand node details.</div>
<!-- TAB: Non-HA -->
<div class="tab-pane fade" id="tab-nonha">
<div class="card border-0">
<div class="card-body table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap" id="nonha">
<thead>
<tr>
<th>SID</th>
<th>Type</th>
<th>Name</th>
<th>Node</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5" class="text-muted">Loading…</td>
</tr>
</tbody>
</table>
<div class="small text-muted">Click a row to expand VM/CT details.</div>
</div>
</div>
</div>
<!-- TAB: Nodes (expandable) -->
<div class="tab-pane fade" id="tab-nodes">
<div class="card border-0">
<div class="card-body table-responsive">
<table class="table table-sm table-striped align-middle table-nowrap" id="nodes">
<thead>
<tr>
<th>Node</th>
<th>Status</th>
<th>CPU</th>
<th>Load</th>
<th>Mem</th>
<th>RootFS</th>
<th>Uptime</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="7" class="text-muted">Loading…</td>
</tr>
</tbody>
</table>
<div class="small text-muted">Click a row to expand node details.</div>
</div>
</div>
</div>
</div>
<div class="text-muted small mt-3" id="footer"></div>
<!-- SITE FOOTER -->
<footer class="site-footer mt-4 pt-3 pb-4 text-muted small">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
<div>© 2025 PVE HA Panel</div>
<div>
Autor: <strong>linuxiarz.pl</strong>
&nbsp;|&nbsp;
Kod źródłowy: <a href="https://gitea.linuxiarz.pl/gru/pve-ha-web" target="_blank"
rel="noopener">gitea.linuxiarz.pl/gru/pve-ha-web</a>
</div>
</div>
</footer>
</div>
<div class="text-muted small mt-3" id="footer"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='main.js') }}"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='main.js') }}"></script>
</body>
</html>