refator_comm1
This commit is contained in:
150
static/js/admin.js
Normal file
150
static/js/admin.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import { badge, rowHTML, setRows, safe, showToast } from './helpers.js';
|
||||
import { api } from './api.js';
|
||||
|
||||
export async function renderVMAdmin() {
|
||||
const data = await api.listAllVmct();
|
||||
const arr = Array.isArray(data.all) ? data.all : [];
|
||||
const nodes = Array.isArray(data.nodes) ? data.nodes : [];
|
||||
const tbody = document.querySelector('#vm-admin tbody');
|
||||
if (!arr.length) { setRows(tbody, [rowHTML(['Brak VM/CT'])]); return; }
|
||||
|
||||
const rows = arr.map(x => {
|
||||
const sid = safe(x.sid), type = safe(x.type), name = safe(x.name), node = safe(x.node);
|
||||
const st = /running/i.test(x.status || '') ? badge(x.status,'ok') : badge(x.status || '—','dark');
|
||||
const actions = `
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary act-unlock">Unlock</button>
|
||||
<button class="btn btn-outline-success act-start">Start</button>
|
||||
<button class="btn btn-outline-warning act-shutdown">Shutdown</button>
|
||||
<button class="btn btn-outline-danger act-stop">Stop</button>
|
||||
</div>`;
|
||||
const sel = `<select class="form-select form-select-sm target-node" style="min-width:140px">
|
||||
${nodes.map(n => `<option value="${n}" ${n === x.node ? 'disabled' : ''}>${n}${n === x.node ? ' (src)' : ''}</option>`).join('')}
|
||||
</select>`;
|
||||
const migrateBtn = `<div class="d-flex align-items-center gap-2">
|
||||
<button class="btn btn-outline-primary btn-sm act-migrate">Migrate (offline)</button>
|
||||
<button class="btn btn-outline-secondary btn-sm act-status">Status</button></div>`;
|
||||
return rowHTML([sid, type.toUpperCase(), name, node, st, actions, sel, migrateBtn], `data-sid="${sid}"`);
|
||||
});
|
||||
|
||||
setRows(tbody, rows);
|
||||
|
||||
Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => {
|
||||
const sid = tr.getAttribute('data-sid');
|
||||
const getTarget = () => tr.querySelector('.target-node')?.value || '';
|
||||
const colSpan = tr.children.length;
|
||||
|
||||
const setRowBusy = (busy) => {
|
||||
const nameCell = tr.children[2]; if (!nameCell) return;
|
||||
let spin = nameCell.querySelector('.op-spin');
|
||||
if (busy) {
|
||||
if (!spin) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'op-spin spinner-border spinner-border-sm align-middle ms-2';
|
||||
span.setAttribute('role','status'); span.setAttribute('aria-hidden','true');
|
||||
nameCell.appendChild(span);
|
||||
}
|
||||
} else { if (spin) spin.remove(); }
|
||||
};
|
||||
|
||||
const ensureMigRow = () => {
|
||||
let nxt = tr.nextElementSibling;
|
||||
if (!nxt || !nxt.classList.contains('mig-row')) {
|
||||
nxt = document.createElement('tr'); nxt.className = 'mig-row d-none';
|
||||
const td = document.createElement('td'); td.colSpan = colSpan;
|
||||
td.innerHTML = '<div class="p-3 border rounded bg-body-tertiary"><div class="fw-semibold mb-1">Migration status</div><pre class="small mb-0 mig-log">—</pre></div>';
|
||||
nxt.appendChild(td); tr.parentNode.insertBefore(nxt, tr.nextSibling);
|
||||
} return nxt;
|
||||
};
|
||||
const setMigRowVisible = (row, vis) => row.classList.toggle('d-none', !vis);
|
||||
|
||||
const tailTaskLog = async (upid, node, preEl, stopSignal) => {
|
||||
let start = 0; const delay = (ms)=>new Promise(r=>setTimeout(r,ms));
|
||||
const append = (txt) => { if (!preEl) return; preEl.textContent = (preEl.textContent?preEl.textContent+'\n':'') + txt; preEl.scrollTop = preEl.scrollHeight; };
|
||||
while (!stopSignal.done) {
|
||||
try {
|
||||
const d = await api.taskLog(upid, node, start);
|
||||
if (d && d.ok) {
|
||||
const lines = Array.isArray(d.lines) ? d.lines : [];
|
||||
for (const ln of lines) if (ln && typeof ln.t === 'string') append(ln.t);
|
||||
start = d.next_start ?? start;
|
||||
}
|
||||
} catch {}
|
||||
await delay(1500);
|
||||
}
|
||||
};
|
||||
|
||||
const pollTask = async (upid, node, onUpdate, onDone) => {
|
||||
let lastSt = null; if (!upid || !node) return;
|
||||
const started = Date.now(); const maxMs = 5*60*1000;
|
||||
const delay = (ms)=>new Promise(r=>setTimeout(r,ms));
|
||||
while (Date.now() - started < maxMs) {
|
||||
try {
|
||||
const d = await api.taskStatus(upid, node);
|
||||
if (d && d.ok) {
|
||||
const st = d.status || {}; lastSt = st;
|
||||
try { onUpdate && onUpdate(st); } catch {}
|
||||
const s = (st.status||'').toLowerCase();
|
||||
if (s === 'stopped' || st.exitstatus) break;
|
||||
}
|
||||
} catch {}
|
||||
await delay(2000);
|
||||
}
|
||||
try { onDone && onDone(lastSt); } catch {}
|
||||
};
|
||||
|
||||
const bind = (selector, action, needsTarget=false) => {
|
||||
const btn = tr.querySelector(selector); if (!btn) return;
|
||||
btn.onclick = async () => {
|
||||
setRowBusy(true); btn.disabled = true;
|
||||
try {
|
||||
if (action === 'migrate') {
|
||||
const target = getTarget();
|
||||
const resp = await api.vmAction(sid, action, target);
|
||||
const row = ensureMigRow(); setMigRowVisible(row, true);
|
||||
const log = row.querySelector('.mig-log');
|
||||
const srcNode = resp.source_node; const upid = resp.upid; const stopSig = { done:false };
|
||||
tailTaskLog(upid, srcNode, log, stopSig);
|
||||
log.textContent = `Starting offline migrate to ${target} (UPID: ${upid || '—'})...`;
|
||||
const badgeCell = tr.children[4]; if (badgeCell) badgeCell.innerHTML = badge('migrating','info');
|
||||
await pollTask(upid, srcNode, (st) => {
|
||||
const keys = ['type','status','pid','starttime','user','node','endtime','exitstatus'];
|
||||
const lines = keys.filter(k=>st && st[k]!==undefined).map(k=>`${k}: ${st[k]}`);
|
||||
log.textContent = lines.join('\n') || '—';
|
||||
}, async (finalSt) => {
|
||||
stopSig.done = true;
|
||||
const exit = (finalSt && finalSt.exitstatus) ? String(finalSt.exitstatus) : '';
|
||||
const ok = exit.toUpperCase() === 'OK';
|
||||
const badgeCell = tr.children[4];
|
||||
if (badgeCell) badgeCell.innerHTML = ok ? badge('running','ok') : badge('migrate error','err');
|
||||
log.textContent += (log.textContent ? '\n' : '') + (ok ? 'Migration finished successfully.' : ('Migration failed: ' + (exit || 'unknown error')));
|
||||
setRowBusy(false);
|
||||
// odśwież minimalnie: tylko ten wiersz przez szybkie /api/list-all-vmct? (tu prosty full-refresh stanu)
|
||||
try { document.getElementById('btnRefresh').click(); } catch {}
|
||||
if (ok) setTimeout(()=>{ const row = tr.nextElementSibling; if (row && row.classList.contains('mig-row')) setMigRowVisible(row,false); }, 2000);
|
||||
});
|
||||
} else {
|
||||
await api.vmAction(sid, action, needsTarget ? getTarget() : undefined);
|
||||
showToast('Success', `${action} executed for ${sid}`, 'success');
|
||||
setRowBusy(false);
|
||||
try { document.getElementById('btnRefresh').click(); } catch {}
|
||||
}
|
||||
} catch (e) { showToast('Error', 'ERROR: ' + (e.message || e), 'danger'); setRowBusy(false); }
|
||||
btn.disabled = false;
|
||||
};
|
||||
};
|
||||
|
||||
bind('.act-unlock','unlock');
|
||||
bind('.act-start','start');
|
||||
bind('.act-stop','stop');
|
||||
bind('.act-shutdown','shutdown');
|
||||
bind('.act-migrate','migrate', true);
|
||||
|
||||
const statusBtn = tr.querySelector('.act-status');
|
||||
if (statusBtn) statusBtn.onclick = () => {
|
||||
const row = tr.nextElementSibling && tr.nextElementSibling.classList.contains('mig-row') ? tr.nextElementSibling : null;
|
||||
if (!row) return;
|
||||
const vis = row.classList.contains('d-none'); row.classList.toggle('d-none', !vis);
|
||||
};
|
||||
});
|
||||
}
|
23
static/js/api.js
Normal file
23
static/js/api.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// Każda funkcja = jedno zapytanie (łatwy prefetch i równoległość)
|
||||
export const api = {
|
||||
snapshot: (node) => fetch(`/api/info?node=${encodeURIComponent(node||'')}`).then(r=>r.json()),
|
||||
|
||||
// lżejsze mikro-endpointy:
|
||||
clusterBrief: () => fetch('/api/cluster').then(r=>r.json()),
|
||||
nodesSummary: () => fetch('/api/nodes/summary').then(r=>r.json()),
|
||||
units: (node) => fetch(`/api/units?node=${encodeURIComponent(node||'')}`).then(r=>r.json()),
|
||||
replicationAll: () => fetch('/api/replication/all').then(r=>r.json()),
|
||||
|
||||
vmDetail: (sid) => fetch('/api/vm?sid=' + encodeURIComponent(sid)).then(r=>r.json()),
|
||||
nodeDetail: (name) => fetch('/api/node?name=' + encodeURIComponent(name)).then(r=>r.json()),
|
||||
listNonHA: () => fetch('/api/list-vmct').then(r=>r.json()),
|
||||
listAllVmct: () => fetch('/api/list-all-vmct').then(r=>r.json()),
|
||||
|
||||
action: (act, node) => fetch('/api/'+act, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({node})}).then(r=>r.json()),
|
||||
vmAction: (sid, action, target) => {
|
||||
const body = { sid, action }; if (target) body.target = target;
|
||||
return fetch('/api/vm-action', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}).then(r=>r.json());
|
||||
},
|
||||
taskStatus: (upid, node) => fetch(`/api/task-status?upid=${encodeURIComponent(upid)}&node=${encodeURIComponent(node)}`).then(r=>r.json()),
|
||||
taskLog: (upid, node, start=0) => fetch(`/api/task-log?upid=${encodeURIComponent(upid)}&node=${encodeURIComponent(node)}&start=${start}`).then(r=>r.json())
|
||||
};
|
53
static/js/helpers.js
Normal file
53
static/js/helpers.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// ------ helpers ------
|
||||
export const $ = (q) => document.querySelector(q);
|
||||
export function safe(v) { return (v === undefined || v === null || v === '') ? '—' : String(v); }
|
||||
export function ensureArr(a) { return Array.isArray(a) ? a : []; }
|
||||
export function pct(p) { if (p == null) return '—'; return (p * 100).toFixed(1) + '%'; }
|
||||
export 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]; }
|
||||
export 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>`;
|
||||
}
|
||||
export function rowHTML(cols, attrs='') { return `<tr ${attrs}>${cols.map(c => `<td>${c ?? '—'}</td>`).join('')}</tr>`; }
|
||||
export function setRows(tbody, rows) { tbody.innerHTML = rows.length ? rows.join('') : rowHTML(['—']); }
|
||||
export 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;
|
||||
}
|
||||
export 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;
|
||||
}
|
||||
export 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;
|
||||
}
|
||||
export function kvGrid(obj, keys, titleMap={}) {
|
||||
return `<div class="row row-cols-1 row-cols-md-2 g-2">${
|
||||
keys.map(k => `<div class="col"><div class="card border-0"><div class="card-body p-2">
|
||||
<div class="text-muted small">${titleMap[k] || k}</div><div class="fw-semibold">${safe(obj[k])}</div>
|
||||
</div></div></div>`).join('')}</div>`;
|
||||
}
|
||||
// prefer first non-empty
|
||||
export function pick(...vals) { for (const v of vals) { if (v !== undefined && v !== null && v !== '') return v; } return ''; }
|
||||
|
||||
// toast
|
||||
export function showToast(title, body, variant) {
|
||||
const cont = document.getElementById('toast-container'); if (!cont) return;
|
||||
const vcls = { success:'text-bg-success', info:'text-bg-info', warning:'text-bg-warning', danger:'text-bg-danger', secondary:'text-bg-secondary' }[variant||'secondary'];
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast align-items-center ' + vcls;
|
||||
el.setAttribute('role','alert'); el.setAttribute('aria-live','assertive'); el.setAttribute('aria-atomic','true');
|
||||
el.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${title||''}</strong>${title?': ':''}${body||''}</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button></div>`;
|
||||
cont.appendChild(el); const t = new bootstrap.Toast(el, { delay: 5000 }); t.show(); el.addEventListener('hidden.bs.toast', () => el.remove());
|
||||
}
|
87
static/js/main.js
Normal file
87
static/js/main.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { $, safe } from './helpers.js';
|
||||
import { api } from './api.js';
|
||||
import { refs, setHealth, renderClusterCards, renderUnits, renderReplicationTable, renderHAResources, renderNonHA, renderNodesTable } from './tables.js';
|
||||
import { renderVMAdmin } from './admin.js';
|
||||
|
||||
// ------ actions ------
|
||||
async function callAction(act) {
|
||||
const node = refs.nodeInput.value || '';
|
||||
const d = await api.action(act, node);
|
||||
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;
|
||||
let ac = null; // AbortController dla równoległych fetchy
|
||||
|
||||
async function doRefresh() {
|
||||
try {
|
||||
if (ac) ac.abort();
|
||||
ac = new AbortController();
|
||||
const node = refs.nodeInput.value || '';
|
||||
// Minimalny szybki zestaw danych — równolegle:
|
||||
const [cluster, nodes, units, repl] = await Promise.allSettled([
|
||||
api.clusterBrief(), api.nodesSummary(), api.units(node), api.replicationAll()
|
||||
]);
|
||||
|
||||
// render
|
||||
const vq = (cluster.value && cluster.value.votequorum) || {};
|
||||
const unitsMap = (units.value && units.value.units) || {};
|
||||
const allUnits = Object.values(unitsMap).every(v => v === 'active');
|
||||
setHealth((vq.quorate === 'yes') && allUnits, vq, allUnits);
|
||||
|
||||
const gl = document.getElementById('global-loading'); if (gl) gl.remove();
|
||||
|
||||
refs.qSummary.textContent = `Quorate: ${safe(vq.quorate)} | members: ${safe(vq.members)} | expected: ${safe(vq.expected)} | total: ${safe(vq.total)} | quorum: ${safe(vq.quorum)}`;
|
||||
renderClusterCards((cluster.value && cluster.value.cluster_status) || []);
|
||||
renderUnits(unitsMap);
|
||||
renderReplicationTable((repl.value || {jobs:[]}));
|
||||
|
||||
renderHAResources((cluster.value && cluster.value.ha_resources) || []);
|
||||
renderNodesTable((nodes.value && nodes.value.nodes) || []);
|
||||
|
||||
refs.pvecmPre.textContent = safe(cluster.value && cluster.value.pvecm);
|
||||
refs.cfgtoolPre.textContent = safe(cluster.value && cluster.value.cfgtool);
|
||||
refs.footer.textContent = `node_arg=${safe(node)} | host=${safe(cluster.value && cluster.value.hostname)} | ts=${new Date(((cluster.value && cluster.value.ts) || 0) * 1000).toLocaleString()}`;
|
||||
|
||||
// pierwszy raz: dociągnij Non-HA + VM Admin w idle
|
||||
if (!doRefresh.didNonHA) { requestIdleCallback(() => renderNonHA().catch(console.error)); doRefresh.didNonHA = true; }
|
||||
if (!doRefresh.didAdmin) { requestIdleCallback(() => renderVMAdmin().catch(console.error)); doRefresh.didAdmin = true; }
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
$('#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));
|
||||
}
|
||||
};
|
||||
|
||||
// initial one-shot load
|
||||
doRefresh().catch(console.error);
|
107
static/js/nodeDetail.js
Normal file
107
static/js/nodeDetail.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import { safe, ensureArr, badge, rowHTML, humanBytes, kvGrid, fmtSeconds, pick } from './helpers.js';
|
||||
|
||||
export 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 subscription = d.subscription || {};
|
||||
|
||||
// online detect
|
||||
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 || '');
|
||||
|
||||
const cpuinfo = st.cpuinfo || {};
|
||||
const boot = st['boot-info'] || st.boot_info || {};
|
||||
const curKernel = st['current-kernel'] || st.current_kernel || {};
|
||||
const ramStr = (mem.used != null && mem.available != null && mem.total != null)
|
||||
? `${humanBytes(mem.used)} used / ${humanBytes(mem.available)} free / ${humanBytes(mem.total)} total`
|
||||
: (mem.total != null ? humanBytes(mem.total) : '—');
|
||||
|
||||
const tech = {
|
||||
'PVE version': pick(st.pveversion, ver.pvemanager, ver['pve-manager']),
|
||||
'Kernel': pick(st.kversion, curKernel.release, ver.kernel, ver.release),
|
||||
'CPU model': pick(cpuinfo.model, st['cpu-model'], ver['cpu-model'], ver.cpu),
|
||||
'Architecture': pick(curKernel.machine, ver.arch, st.architecture, st.arch),
|
||||
'RAM': ramStr,
|
||||
'Boot mode': pick(boot.mode) ? String(boot.mode).toUpperCase() : '—',
|
||||
'Secure Boot': (boot.secureboot === 1 || boot.secureboot === '1') ? 'enabled' :
|
||||
(boot.secureboot === 0 || boot.secureboot === '0') ? 'disabled' : '—'
|
||||
};
|
||||
|
||||
const top = `
|
||||
<div class="d-flex flex-wrap align-items-center gap-3 mb-2">
|
||||
<div class="fw-bold">${safe(d.node)}</div>
|
||||
<div class="vr"></div><div>${sB}</div>
|
||||
<div class="vr"></div>
|
||||
<div class="small text-muted">CPU: ${safe(((st.cpu??null)!==null)?(st.cpu*100).toFixed(1)+'%':'—')}</div>
|
||||
<div class="small text-muted">Load: ${safe(load)}</div>
|
||||
<div class="small text-muted">Uptime: ${fmtSeconds(st.uptime)}</div>
|
||||
</div>`;
|
||||
|
||||
const memCard = `
|
||||
<div class="row row-cols-2 row-cols-md-4 g-2">
|
||||
<div class="col"><div class="card border-0"><div class="card-body p-2">
|
||||
<div class="text-muted small">Memory</div>
|
||||
<div class="fw-semibold">${(mem.used != null && mem.total != null) ? `${humanBytes(mem.used)} / ${humanBytes(mem.total)} (${((mem.used/mem.total)*100).toFixed(1)}%)` : '—'}</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)} (${((root.used/root.total)*100).toFixed(1)}%)` : '—'}</div>
|
||||
</div></div></div>
|
||||
<div class="col"><div class="card border-0"><div class="card-body p-2">
|
||||
<div class="text-muted small">Kernel / QEMU</div>
|
||||
<div class="fw-semibold">${safe(tech['Kernel'])} / ${safe(pick(ver.qemu, ver['running-qemu']))}</div>
|
||||
</div></div></div>
|
||||
<div class="col"><div class="card border-0"><div class="card-body p-2">
|
||||
<div class="text-muted small">Time</div>
|
||||
<div class="fw-semibold">${safe(tm.localtime)} ${tm.timezone ? `(${tm.timezone})` : ''}</div>
|
||||
</div></div></div>
|
||||
</div>`;
|
||||
|
||||
const sysDetails = kvGrid(tech, Object.keys(tech), {
|
||||
'PVE version': 'PVE version','Kernel':'Kernel version','CPU model':'CPU model',
|
||||
'Architecture':'Arch','RAM':'RAM (used/free/total)','Boot mode':'Boot mode','Secure Boot':'Secure Boot'
|
||||
});
|
||||
|
||||
const netRows = ensureArr(netcfg).map(n => 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>`;
|
||||
|
||||
const diskRows = ensureArr(disks).map(dv => rowHTML([safe(dv.devpath||dv.kname||dv.dev), safe(dv.model), safe(dv.size?humanBytes(dv.size):'—'), safe(dv.health||dv.wearout||'—'), safe(dv.serial||'—')]));
|
||||
const diskTable = `<div class="table-responsive"><table class="table table-sm table-striped align-middle table-nowrap">
|
||||
<thead><tr><th>Device</th><th>Model</th><th>Size</th><th>Health</th><th>Serial</th></tr></thead>
|
||||
<tbody>${diskRows.length ? diskRows.join('') : rowHTML(['—','—','—','—','—'])}</tbody></table></div>`;
|
||||
|
||||
// Subscription: ukryj, gdy notfound/No subscription key
|
||||
const subTxt = (subscription.message||'') + ' ' + (subscription.status||'');
|
||||
const hideSub = /notfound/i.test(subTxt) || /no subscription key/i.test(subTxt);
|
||||
const subBox = hideSub ? '' : `
|
||||
<div class="mt-3"><div class="fw-semibold mb-1">Subscription</div>
|
||||
<div class="small">
|
||||
<div>Status: ${badge(safe(subscription.status||'unknown'), /active|valid/i.test(subscription.status||'') ? 'ok':'warn')}</div>
|
||||
${subscription.productname ? `<div>Product: <strong>${safe(subscription.productname)}</strong></div>` : ''}
|
||||
${subscription.message ? `<div class="text-muted">${safe(subscription.message)}</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const rawId = `raw-node-${safe(d.node)}`;
|
||||
const rawBtn = `<button class="btn btn-sm btn-outline-secondary mt-3" type="button" data-bs-toggle="collapse" data-bs-target="#${rawId}">Show raw JSON</button>`;
|
||||
const rawBox = `<div id="${rawId}" class="collapse mt-2"><pre class="small mb-0">${JSON.stringify(d, null, 2)}</pre></div>`;
|
||||
|
||||
return `${top}${memCard}
|
||||
<div class="mt-3"><div class="fw-semibold mb-1">System details</div>${sysDetails}</div>
|
||||
<div class="mt-3"><div class="fw-semibold mb-1">Network (config)</div>${netCfgTable}</div>
|
||||
<div class="mt-3"><div class="fw-semibold mb-1">Disks</div>${diskTable}</div>
|
||||
${subBox}
|
||||
${rawBtn}${rawBox}`;
|
||||
}
|
191
static/js/tables.js
Normal file
191
static/js/tables.js
Normal file
@@ -0,0 +1,191 @@
|
||||
import { $, safe, ensureArr, badge, rowHTML, setRows, pct, humanBytes, fmtSeconds, kvGrid, showToast } from './helpers.js';
|
||||
import { api } from './api.js';
|
||||
import { renderVmDetailCard } from './vmDetail.js';
|
||||
import { renderNodeDetailCard } from './nodeDetail.js';
|
||||
|
||||
// DOM refs
|
||||
export const refs = {
|
||||
nodeInput: $('#node'),
|
||||
healthDot: $('#healthDot'), healthTitle: $('#healthTitle'), healthSub: $('#healthSub'),
|
||||
qSummary: $('#q-summary'), qCardsWrap: $('#q-cards'), unitsBox: $('#units'), replBox: $('#repl'),
|
||||
tblHaRes: $('#ha-res'), tblHaStatus: $('#ha-status'), // (tblHaStatus nieużywany, sekcja wycięta w HTML)
|
||||
tblNodes: $('#nodes'), tblNonHA: $('#nonha'),
|
||||
pvecmPre: $('#pvecm'), cfgtoolPre: $('#cfgtool'), footer: $('#footer')
|
||||
};
|
||||
|
||||
// Health
|
||||
export function setHealth(ok, vq, unitsActive) {
|
||||
refs.healthDot.classList.toggle('ok', !!ok);
|
||||
refs.healthDot.classList.toggle('bad', !ok);
|
||||
refs.healthTitle.textContent = ok ? 'HA: OK' : 'HA: PROBLEM';
|
||||
refs.healthSub.textContent = `Quorate=${String(vq.quorate)} | units=${unitsActive ? 'active' : 'inactive'} | members=${safe(vq.members)} | quorum=${safe(vq.quorum)}/${safe(vq.expected)}`;
|
||||
}
|
||||
|
||||
// Cluster cards
|
||||
export function renderClusterCards(arr) {
|
||||
const a = ensureArr(arr); refs.qCardsWrap.innerHTML = '';
|
||||
if (!a.length) { refs.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');
|
||||
refs.qCardsWrap.insertAdjacentHTML('beforeend', `
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm"><div class="card-body d-flex flex-wrap align-items-center gap-3">
|
||||
<div class="fw-bold">${safe(cluster.name)}</div>
|
||||
<div class="text-muted small">id: ${safe(cluster.id)}</div><div class="vr"></div>
|
||||
<div>${qB}</div><div class="vr"></div>
|
||||
<div class="small">nodes: <strong>${safe(cluster.nodes)}</strong></div>
|
||||
<div class="small">version: <strong>${safe(cluster.version)}</strong></div>
|
||||
</div></div>
|
||||
</div>`);
|
||||
const nodes = a.filter(x => x.type === 'node');
|
||||
const rows = nodes.map(n => {
|
||||
const online = n.online ? badge('online','ok') : badge('offline','err');
|
||||
const local = n.local ? ' ' + badge('local','info') : '';
|
||||
return rowHTML([safe(n.name), online + local, safe(n.ip), safe(n.nodeid), safe(n.level)]);
|
||||
});
|
||||
refs.qCardsWrap.insertAdjacentHTML('beforeend', `
|
||||
<div class="col-12"><div class="card border-0"><div class="card-body table-responsive pt-2">
|
||||
<table class="table table-sm table-striped align-middle table-nowrap">
|
||||
<thead><tr><th>Node</th><th>Status</th><th>IP</th><th>NodeID</th><th>Level</th></tr></thead>
|
||||
<tbody>${rows.join('')}</tbody>
|
||||
</table>
|
||||
</div></div></div>`);
|
||||
}
|
||||
|
||||
export function renderUnits(units) {
|
||||
refs.unitsBox.innerHTML = '';
|
||||
if (!units || !Object.keys(units).length) { refs.unitsBox.innerHTML = badge('No data','dark'); return; }
|
||||
const map = { active:'ok', inactive:'err', failed:'err', activating:'warn' };
|
||||
Object.entries(units).forEach(([k, v]) => refs.unitsBox.insertAdjacentHTML('beforeend', `<span class="me-2">${badge(k, map[v] || 'dark')}</span>`));
|
||||
}
|
||||
|
||||
// Replication — ALL NODES
|
||||
export function renderReplicationTable(repAll) {
|
||||
const arr = ensureArr(repAll.jobs);
|
||||
if (!arr.length) { refs.replBox && (refs.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([safe(x.node), safe(x.job), en, safe(x.target), safe(x.last), safe(x.next), safe(x.dur), fc, st]);
|
||||
});
|
||||
refs.replBox.innerHTML = `<div class="table-responsive"><table class="table table-sm table-striped align-middle table-nowrap">
|
||||
<thead><tr><th>Node</th><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 (expand)
|
||||
export function renderHAResources(list) {
|
||||
const tbody = refs.tblHaRes.querySelector('tbody');
|
||||
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([`<span class="chev"></span>`, sid, stB, safe(x.node), safe(x.group), safe(x.flags || x.comment)], `class="expandable vm-row" data-sid="${sid}"`));
|
||||
rows.push(`<tr class="vm-detail d-none"><td colspan="6"><div class="spinner-border spinner-border-sm me-2 d-none"></div><div class="vm-json">—</div></td></tr>`);
|
||||
});
|
||||
setRows(tbody, rows.length ? rows : [rowHTML(['—','—','—','—','—','—'])]);
|
||||
|
||||
Array.from(refs.tblHaRes.querySelectorAll('tr.vm-row')).forEach((tr, i) => {
|
||||
tr.onclick = async () => {
|
||||
const detailRow = refs.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');
|
||||
// collapse all
|
||||
refs.tblHaRes.querySelectorAll('tr.vm-detail').forEach(r => r.classList.add('d-none'));
|
||||
refs.tblHaRes.querySelectorAll('tr.vm-row').forEach(r => r.classList.remove('expanded'));
|
||||
if (open) {
|
||||
detailRow.classList.remove('d-none'); tr.classList.add('expanded');
|
||||
spin.classList.remove('d-none');
|
||||
const sid = tr.getAttribute('data-sid');
|
||||
try { const d = await api.vmDetail(sid); content.innerHTML = renderVmDetailCard(d); }
|
||||
catch (e) { content.textContent = 'ERROR: ' + e; }
|
||||
spin.classList.add('d-none');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Non-HA VM/CT (expand)
|
||||
export async function renderNonHA() {
|
||||
const r = await api.listNonHA();
|
||||
const arr = ensureArr(r.nonha);
|
||||
const tbody = refs.tblNonHA.querySelector('tbody');
|
||||
if (!arr.length) { setRows(tbody, [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([`<span class="chev"></span>`, sid, type, name, node, st], `class="expandable vm-row" data-sid="${sid}"`));
|
||||
rows.push(`<tr class="vm-detail d-none"><td colspan="6"><div class="spinner-border spinner-border-sm me-2 d-none"></div><div class="vm-json">—</div></td></tr>`);
|
||||
});
|
||||
setRows(tbody, rows);
|
||||
Array.from(tbody.querySelectorAll('tr.vm-row')).forEach((tr, i) => {
|
||||
tr.onclick = async () => {
|
||||
const detailRow = tbody.querySelectorAll('tr.vm-detail')[i];
|
||||
const content = detailRow.querySelector('.vm-json');
|
||||
const spin = detailRow.querySelector('.spinner-border');
|
||||
const open = detailRow.classList.contains('d-none');
|
||||
tbody.querySelectorAll('tr.vm-detail').forEach(r => r.classList.add('d-none'));
|
||||
tbody.querySelectorAll('tr.vm-row').forEach(r => r.classList.remove('expanded'));
|
||||
if (open) {
|
||||
detailRow.classList.remove('d-none'); tr.classList.add('expanded');
|
||||
spin.classList.remove('d-none');
|
||||
const sid = tr.getAttribute('data-sid');
|
||||
try { const d = await api.vmDetail(sid); content.innerHTML = renderVmDetailCard(d); }
|
||||
catch (e) { content.textContent = 'ERROR: ' + e; }
|
||||
spin.classList.add('d-none');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Nodes table (expand)
|
||||
export function renderNodesTable(nodes) {
|
||||
const tbody = refs.tblNodes.querySelector('tbody');
|
||||
const nrows = ensureArr(nodes).map(n => {
|
||||
const isOn = /online|running/i.test(n.status || '') ||
|
||||
/online/i.test(n.hastate || '') || (n.uptime > 0) ||
|
||||
(n.cpu != null && n.maxcpu != null) || (n.mem != null && n.maxmem != null);
|
||||
const statusTxt = isOn ? 'online' : (n.status || 'offline');
|
||||
const sB = isOn ? badge(statusTxt,'ok') : badge(statusTxt,'err');
|
||||
|
||||
const mem = (n.mem != null && n.maxmem) ? `${humanBytes(n.mem)} / ${humanBytes(n.maxmem)} (${pct(n.mem / n.maxmem)})` : '—';
|
||||
const rfs = (n.rootfs != null && n.maxrootfs) ? `${humanBytes(n.rootfs)} / ${humanBytes(n.maxrootfs)} (${pct(n.rootfs / n.maxrootfs)})` : '—';
|
||||
const load = (n.loadavg != null) ? String(n.loadavg) : '—';
|
||||
const cpu = (n.cpu != null) ? pct(n.cpu) : '—';
|
||||
|
||||
const main = `<tr class="expandable node-row" data-node="${safe(n.node)}">
|
||||
<td class="chev"></td><td class="sticky-col fw-semibold">${safe(n.node)}</td>
|
||||
<td>${sB}</td><td>${cpu}</td><td>${load}</td><td>${mem}</td><td>${rfs}</td><td>${fmtSeconds(n.uptime)}</td>
|
||||
</tr>`;
|
||||
|
||||
const detail = `<tr class="node-detail d-none"><td colspan="8">
|
||||
<div class="spinner-border spinner-border-sm me-2 d-none"></div><div class="node-json">—</div>
|
||||
</td></tr>`;
|
||||
|
||||
return main + detail;
|
||||
});
|
||||
setRows(tbody, nrows);
|
||||
|
||||
Array.from(tbody.querySelectorAll('tr.node-row')).forEach((tr, i) => {
|
||||
tr.onclick = async () => {
|
||||
const detailRow = tbody.querySelectorAll('tr.node-detail')[i];
|
||||
const content = detailRow.querySelector('.node-json');
|
||||
const spin = detailRow.querySelector('.spinner-border');
|
||||
const open = detailRow.classList.contains('d-none');
|
||||
tbody.querySelectorAll('tr.node-detail').forEach(r => r.classList.add('d-none'));
|
||||
tbody.querySelectorAll('tr.node-row').forEach(r => r.classList.remove('expanded'));
|
||||
if (open) {
|
||||
detailRow.classList.remove('d-none'); tr.classList.add('expanded');
|
||||
spin.classList.remove('d-none');
|
||||
const name = tr.getAttribute('data-node');
|
||||
try { const d = await api.nodeDetail(name); content.innerHTML = renderNodeDetailCard(d); }
|
||||
catch (e) { content.textContent = 'ERROR: ' + e; }
|
||||
spin.classList.add('d-none');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
136
static/js/vmDetail.js
Normal file
136
static/js/vmDetail.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { safe, ensureArr, badge, rowHTML, humanBytes, kvGrid, fmtSeconds, parseVmNetworks } from './helpers.js';
|
||||
|
||||
export function renderVmDetailCard(d) {
|
||||
// --- dokładnie ten sam kod, który miałeś — przeniesiony bez zmian ---
|
||||
// (skrócone dla czytelności — wklejam pełną wersję z Twojego pliku)
|
||||
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), String(s.rd_operations||0),
|
||||
humanBytes(s.wr_bytes||0), String(s.wr_operations||0), String(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)': `${String(cur.pressurecpusome||'—')}/${String(cur.pressurecpufull||'—')}`,
|
||||
'Pressure IO (some/full)': `${String(cur.pressureiosome||'—')}/${String(cur.pressureiofull||'—')}`,
|
||||
'Pressure MEM (some/full)': `${String(cur.pressurememorysome||'—')}/${String(cur.pressurememoryfull||'—')}`
|
||||
};
|
||||
|
||||
const nets = parseVmNetworks(cfg);
|
||||
const netRows = nets.map(n => {
|
||||
const br = n.bridge || n.br || '—';
|
||||
const mdl = n.model || n.type || (n.raw?.split(',')[0]?.split('=')[0]) || 'virtio';
|
||||
const mac = n.hwaddr || n.mac || (n.raw?.match(/([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}/)?.[0] || '—');
|
||||
const vlan = n.tag || n.vlan || '—';
|
||||
const fw = (n.firewall === '1') ? badge('on','warn') : badge('off','dark');
|
||||
return rowHTML([`net${n.idx}`, mdl, br, vlan, mac, fw]);
|
||||
});
|
||||
|
||||
const netTable = `
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle table-nowrap">
|
||||
<thead><tr><th>IF</th><th>Model</th><th>Bridge</th><th>VLAN</th><th>MAC</th><th>FW</th></tr></thead>
|
||||
<tbody>${netRows.length ? netRows.join('') : rowHTML(['—','—','—','—','—','—'])}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
const agentSummary = (agInfo || agOS || guestIPs.length)
|
||||
? `<div class="small">
|
||||
${agOS ? `<div>Guest OS: <strong>${safe(guestName)}</strong></div>` : ''}
|
||||
${guestIPs.length ? `<div>Guest IPs: ${guestIPs.map(ip => badge(ip,'info')).join(' ')}</div>` : ''}
|
||||
${agInfo ? `<div>Agent: ${badge('present','ok')}</div>` : `<div>Agent: ${badge('not available','err')}</div>`}
|
||||
</div>`
|
||||
: '<div class="text-muted small">No guest agent data</div>';
|
||||
|
||||
const cfgFacts = {
|
||||
'BIOS': cfg.bios, 'UEFI/EFI disk': cfg.efidisk0 ? 'yes' : 'no',
|
||||
'CPU type': cfg.cpu, 'Sockets': cfg.sockets, 'Cores': cfg.cores, 'NUMA': cfg.numa,
|
||||
'On boot': cfg.onboot ? 'yes' : 'no', 'OS type': cfg.ostype, 'SCSI hw': cfg.scsihw
|
||||
};
|
||||
|
||||
const rawId = `raw-${d.type}-${d.vmid}`;
|
||||
const rawBtn = `<button class="btn btn-sm btn-outline-secondary mt-3" type="button" data-bs-toggle="collapse" data-bs-target="#${rawId}">Show raw JSON</button>`;
|
||||
const rawBox = `<div id="${rawId}" class="collapse mt-2">
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#rt-${d.vmid}" type="button">Runtime</button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#cfg-${d.vmid}" type="button">Config</button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#agt-${d.vmid}" type="button">Agent</button></li>
|
||||
</ul>
|
||||
<div class="tab-content border-top pt-3">
|
||||
<div class="tab-pane fade show active" id="rt-${d.vmid}"><pre class="small mb-0">${JSON.stringify(cur,null,2)}</pre></div>
|
||||
<div class="tab-pane fade" id="cfg-${d.vmid}"><pre class="small mb-0">${JSON.stringify(cfg,null,2)}</pre></div>
|
||||
<div class="tab-pane fade" id="agt-${d.vmid}"><pre class="small mb-0">${JSON.stringify(d.agent||{},null,2)}</pre></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
return `
|
||||
<div class="d-flex flex-wrap align-items-center gap-3 mb-2">
|
||||
<div class="fw-bold">${safe(meta.name || cfg.name || d.sid)}</div>
|
||||
<div class="text-muted small">${(d.type || '').toUpperCase()} / VMID ${safe(d.vmid)} @ ${safe(d.node)}</div>
|
||||
<div class="vr"></div><div>${statusBadge}</div>
|
||||
${meta.hastate ? `<div class="vr"></div><div class="small">HA: ${badge(meta.hastate, /started/i.test(meta.hastate) ? 'ok' : 'warn')}</div>` : ''}
|
||||
${ha.state ? `<div class="vr"></div><div class="small">HA runtime: ${haBadge}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="row row-cols-2 row-cols-md-4 g-2">
|
||||
<div class="col"><div class="card border-0"><div class="card-body p-2">
|
||||
<div class="text-muted small">CPU</div><div class="fw-semibold">${cur.cpu !== undefined ? (cur.cpu * 100).toFixed(1) + '%' : '—'}</div>
|
||||
<div class="small text-muted">vCPUs: ${safe(cur.cpus)}</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></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></div></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3"><div class="fw-semibold mb-1">Network (config)</div>${netTable}</div>
|
||||
<div class="mt-3"><div class="fw-semibold mb-1">Disks (block statistics)</div>
|
||||
<div class="table-responsive"><table class="table table-sm table-striped align-middle table-nowrap">
|
||||
<thead><tr><th>Device</th><th>Read bytes</th><th>Read ops</th><th>Write bytes</th><th>Write ops</th><th>Flush ops</th><th>Highest offset</th></tr></thead>
|
||||
<tbody>${Object.keys(bstat).length ? bRows.join('') : rowHTML(['—','—','—','—','—','—','—'])}</tbody></table></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3"><div class="fw-semibold mb-1">System / QEMU</div>${kvGrid(sysCards, Object.keys(sysCards))}</div>
|
||||
<div class="mt-3 mb-1"><div class="fw-semibold">Config facts</div>${kvGrid(cfgFacts, Object.keys(cfgFacts))}</div>
|
||||
<div class="mt-2">${agentSummary}</div>
|
||||
${rawBtn}${rawBox}
|
||||
`;
|
||||
}
|
Reference in New Issue
Block a user