- configure_vm: capture resize UPID and call wait_for_task(120s) before returning; prevents lock conflict when VM is started right after disk resize - resize_vm_disk / resize_container_disk (x2): same fix applied - remove unnecessary restart_vm() after manual QEMU disk resize — resize is a storage-layer operation and does not require a reboot; the auto-restart was causing a second lock conflict Fixes: can't lock file '/var/lock/qemu-server/lock-xxx.conf' - got timeout Fixes: command '/usr/bin/qemu-img resize' failed: got timeout
701 lines
33 KiB
HTML
701 lines
33 KiB
HTML
{% extends "base.html" %}
|
|
{% set page_title = t('nav_dashboard') %}
|
|
|
|
{% block page_subtitle %}
|
|
<div class="page-subtitle" id="dashSubtitle">
|
|
{%- if stats.online_servers > 0 -%}
|
|
{{ stats.online_servers }} {{ t('online') | lower }}
|
|
{%- else -%}
|
|
{{ t('proxmox_servers') }}: {{ stats.total_servers }}
|
|
{%- endif %}
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<!-- ======== 5 STAT CARDS ======== -->
|
|
<div class="stats-grid-5">
|
|
<div class="stat-card-v2">
|
|
<div class="stat-card-lbl">{{ t('nodes_label') }}</div>
|
|
<div class="stat-card-val">{{ stats.total_servers }}</div>
|
|
<div class="stat-card-sub" id="statNodesSub">
|
|
{%- if stats.online_servers == stats.total_servers and stats.total_servers > 0 -%}
|
|
{{ t('all_online') }}
|
|
{%- elif stats.online_servers > 0 -%}
|
|
{{ stats.online_servers }} {{ t('online') | lower }}
|
|
{%- else -%}
|
|
—
|
|
{%- endif -%}
|
|
</div>
|
|
</div>
|
|
<div class="stat-card-v2">
|
|
<div class="stat-card-lbl">{{ t('vms') }}</div>
|
|
<div class="stat-card-val skeleton" id="statVmCount">—</div>
|
|
<div class="stat-card-sub" id="statVmSub">{{ t('loading') }}…</div>
|
|
</div>
|
|
<div class="stat-card-v2">
|
|
<div class="stat-card-lbl">{{ t('containers') }}</div>
|
|
<div class="stat-card-val skeleton" id="statCtCount">—</div>
|
|
<div class="stat-card-sub" id="statCtSub">{{ t('loading') }}…</div>
|
|
</div>
|
|
<div class="stat-card-v2">
|
|
<div class="stat-card-lbl">{{ t('clusters_label') }}</div>
|
|
<div class="stat-card-val">{{ stats.total_clusters }}</div>
|
|
<div class="stat-card-sub" id="statClusterSub">
|
|
{%- if stats.total_alerts == 0 -%}{{ t('all_healthy') }}{%- else -%}—{%- endif -%}
|
|
</div>
|
|
</div>
|
|
<div class="stat-card-v2 {% if stats.critical_alerts > 0 %}card-alerts{% endif %}">
|
|
<div class="stat-card-lbl">{{ t('alerts_label') }}</div>
|
|
<div class="stat-card-val">{{ stats.total_alerts }}</div>
|
|
<div class="stat-card-sub">
|
|
{%- if stats.critical_alerts > 0 -%}
|
|
<span style="color: var(--danger);">{{ stats.critical_alerts }} {{ t('critical') | lower }}</span>
|
|
{%- elif stats.total_alerts > 0 -%}
|
|
{{ t('last_24h') }}
|
|
{%- else -%}
|
|
{{ t('no_alerts') }}
|
|
{%- endif -%}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ======== RESOURCE CARDS (CPU / RAM / STORAGE) ======== -->
|
|
<div style="position:relative;">
|
|
<div class="resource-cards-row" id="resourceCardsRow">
|
|
<!-- refresh spinner (shown while fetching) -->
|
|
<span id="dashRefreshSpin" title="{{ t('loading') }}" style="
|
|
position:absolute; top:-1.6rem; right:0;
|
|
font-size:.75rem; color:var(--text-muted); opacity:0;
|
|
transition:opacity .2s; pointer-events:none;
|
|
"><i class="fa-solid fa-arrows-rotate fa-spin" style="margin-right:.3rem;"></i><span id="dashLastUpdated"></span></span>
|
|
<div class="resource-card">
|
|
<div class="resource-card-header">
|
|
<span class="resource-card-title">{{ t('cpu_usage') }}</span>
|
|
<span class="resource-pct-badge skeleton" id="cpuPct">—</span>
|
|
</div>
|
|
<div class="resource-mini-chart">
|
|
<canvas id="cpuMiniChart"></canvas>
|
|
</div>
|
|
<div class="resource-progress-bar">
|
|
<div class="resource-progress-fill cpu-fill" id="cpuBar" style="width: 0%"></div>
|
|
</div>
|
|
</div>
|
|
<div class="resource-card">
|
|
<div class="resource-card-header">
|
|
<span class="resource-card-title">{{ t('ram_usage') }}</span>
|
|
<span class="resource-pct-badge skeleton" id="ramPct">—</span>
|
|
</div>
|
|
<div class="resource-mini-chart">
|
|
<canvas id="ramMiniChart"></canvas>
|
|
</div>
|
|
<div class="resource-progress-bar">
|
|
<div class="resource-progress-fill ram-fill" id="ramBar" style="width: 0%"></div>
|
|
</div>
|
|
</div>
|
|
<div class="resource-card">
|
|
<div class="resource-card-header">
|
|
<span class="resource-card-title">{{ t('storage') }}</span>
|
|
<span class="resource-pct-badge skeleton" id="diskPct">—</span>
|
|
</div>
|
|
<div class="resource-mini-chart">
|
|
<canvas id="diskMiniChart"></canvas>
|
|
</div>
|
|
<div class="resource-progress-bar">
|
|
<div class="resource-progress-fill disk-fill" id="diskBar" style="width: 0%"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ======== VM TABLE + NODES PANEL ======== -->
|
|
<div class="dash-main-grid">
|
|
<!-- VM Table -->
|
|
<div class="dash-section">
|
|
<div class="dash-section-header">
|
|
<span class="dash-section-title">{{ t('virtual_machines') }}</span>
|
|
<div style="display:flex; gap:0.75rem; align-items:center;">
|
|
<span class="dash-section-meta" id="vmTableMeta">—</span>
|
|
<a href="/virtual-machines" class="dash-section-link">{{ t('view_all') }}</a>
|
|
</div>
|
|
</div>
|
|
<div style="overflow-x: auto;">
|
|
<table class="vm-table-compact">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t('name') }}</th>
|
|
<th>{{ t('node') }}</th>
|
|
<th>{{ t('cpu') }}</th>
|
|
<th>{{ t('ram') }}</th>
|
|
<th>{{ t('status') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="vmTableBody">
|
|
<tr><td colspan="5" style="text-align:center; color:var(--text-muted); padding:2rem 1.25rem;">
|
|
<i class="fa-solid fa-circle-notch fa-spin" style="margin-right:.5rem;"></i> {{ t('loading') }}…
|
|
</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Nodes Panel -->
|
|
<div class="dash-section">
|
|
<div class="dash-section-header">
|
|
<span class="dash-section-title">{{ t('nodes_label') }}</span>
|
|
<span class="dash-section-meta" id="nodesOnlineMeta">—</span>
|
|
</div>
|
|
<div class="nodes-panel" id="nodesPanel">
|
|
<div style="text-align:center; color:var(--text-muted); padding:2rem 1.25rem;">
|
|
<i class="fa-solid fa-circle-notch fa-spin" style="margin-right:.5rem;"></i> {{ t('loading') }}…
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ======== RECENT AUDIT EVENTS ======== -->
|
|
<div class="dash-section" style="margin-bottom: var(--spacing-lg);">
|
|
<div class="dash-section-header">
|
|
<span class="dash-section-title">{{ t('recent_audit_events') }}</span>
|
|
<a href="/logs" class="dash-section-link">{{ t('view_all') }}</a>
|
|
</div>
|
|
<div class="audit-events-list" id="auditEventsList">
|
|
{% if recent_audit %}
|
|
{% for entry in recent_audit %}
|
|
<div class="audit-row">
|
|
<span class="audit-time">
|
|
{{ entry.created_at.strftime('%H:%M:%S') if entry.created_at else '—' }}
|
|
</span>
|
|
<span class="audit-user">{{ entry.username or '—' }}</span>
|
|
<span>
|
|
<span class="audit-action-tag {{ entry.level | lower if entry.level else 'default' }}">
|
|
{{ entry.action or '—' }}
|
|
</span>
|
|
</span>
|
|
<span class="audit-resource">{{ entry.resource_name or '—' }}</span>
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<div style="text-align:center; color:var(--text-muted); padding:1.5rem 1.25rem; font-size:0.875rem;">
|
|
{{ t('no_audit_events') }}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ======== Dashboard JS ========
|
|
|
|
// Colour the % badge based on value
|
|
function setPctBadgeColor(el, pct) {
|
|
el.classList.remove('pct-ok', 'pct-warn', 'pct-high');
|
|
if (pct < 60) el.classList.add('pct-ok');
|
|
else if (pct < 85) el.classList.add('pct-warn');
|
|
else el.classList.add('pct-high');
|
|
}
|
|
|
|
// Draw a mini line chart on a canvas — updates in-place on subsequent calls
|
|
function drawMiniLineChart(canvasId, dataPoints, color, bgColor) {
|
|
if (typeof Chart === 'undefined') return;
|
|
const canvas = document.getElementById(canvasId);
|
|
if (!canvas) return;
|
|
if (canvas._chartInstance) {
|
|
// Update data in-place (no flicker, smooth transition)
|
|
canvas._chartInstance.data.labels = dataPoints.map((_, i) => i);
|
|
canvas._chartInstance.data.datasets[0].data = dataPoints;
|
|
canvas._chartInstance.update('active');
|
|
return;
|
|
}
|
|
const ctx = canvas.getContext('2d');
|
|
canvas._chartInstance = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: dataPoints.map((_, i) => i),
|
|
datasets: [{
|
|
data: dataPoints,
|
|
borderColor: color,
|
|
backgroundColor: bgColor,
|
|
borderWidth: 2,
|
|
tension: 0.4,
|
|
fill: true,
|
|
pointRadius: 0,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: { duration: 400 },
|
|
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
|
scales: {
|
|
x: { display: false },
|
|
y: { display: false, beginAtZero: true, max: 100 }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Draw a mini bar chart (for storage) — updates in-place on subsequent calls
|
|
function drawMiniBarChart(canvasId, dataPoints, color) {
|
|
if (typeof Chart === 'undefined') return;
|
|
const canvas = document.getElementById(canvasId);
|
|
if (!canvas) return;
|
|
if (canvas._chartInstance) {
|
|
canvas._chartInstance.data.labels = dataPoints.map((_, i) => i);
|
|
canvas._chartInstance.data.datasets[0].data = dataPoints;
|
|
canvas._chartInstance.update('active');
|
|
return;
|
|
}
|
|
const ctx = canvas.getContext('2d');
|
|
canvas._chartInstance = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: dataPoints.map((_, i) => i),
|
|
datasets: [{
|
|
data: dataPoints,
|
|
backgroundColor: color,
|
|
borderRadius: 3,
|
|
borderSkipped: false,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: { duration: 400 },
|
|
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
|
scales: {
|
|
x: { display: false },
|
|
y: { display: false, beginAtZero: true, max: 100 }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Refresh spinner helpers
|
|
function showRefreshSpin(visible) {
|
|
const el = document.getElementById('dashRefreshSpin');
|
|
if (el) el.style.opacity = visible ? '1' : '0';
|
|
}
|
|
function markLastUpdated() {
|
|
const el = document.getElementById('dashLastUpdated');
|
|
if (!el) return;
|
|
const now = new Date();
|
|
el.textContent = now.toLocaleTimeString(
|
|
'{{ "ru-RU" if lang == "ru" else "en-US" }}',
|
|
{ hour: '2-digit', minute: '2-digit', second: '2-digit' }
|
|
);
|
|
}
|
|
|
|
// Inner function: fetch node stats for already-known online servers
|
|
async function _refreshNodeStats(onlineServers) {
|
|
try {
|
|
|
|
// ---------- Step 1: fetch node lists for all servers IN PARALLEL ----------
|
|
const nodeListResults = await Promise.all(
|
|
onlineServers.map(async server => {
|
|
// server here may be from /resources/all (has .id) or /servers (has .id + .is_online)
|
|
try {
|
|
const r = await fetchWithAuth(`/proxmox/api/${server.id}/nodes`, { hideOnForbidden: true });
|
|
if (!r || !r.ok) return null;
|
|
const d = await r.json();
|
|
return { server, nodes: d.nodes || [], clusterId: d.cluster_id || `standalone-${server.id}` };
|
|
} catch (e_) { return null; }
|
|
})
|
|
);
|
|
|
|
// de-duplicate across clusters
|
|
const seenNodes = new Set();
|
|
const uniqueNodes = [];
|
|
for (const result of nodeListResults) {
|
|
if (!result) continue;
|
|
for (const nodeInfo of result.nodes) {
|
|
const key = `${result.clusterId}::${nodeInfo.node}`;
|
|
if (seenNodes.has(key)) continue;
|
|
seenNodes.add(key);
|
|
uniqueNodes.push({ server: result.server, nodeName: nodeInfo.node, maxVms: nodeInfo.maxvms || 0 });
|
|
}
|
|
}
|
|
if (uniqueNodes.length === 0) return;
|
|
|
|
// ---------- Step 2: fetch status AND rrd for ALL nodes IN PARALLEL ----------
|
|
const [statusResults, rrdResults] = await Promise.all([
|
|
Promise.all(uniqueNodes.map(async ({ server, nodeName, maxVms }) => {
|
|
try {
|
|
const r = await fetchWithAuth(`/proxmox/api/${server.id}/node/status?node=${nodeName}`, { hideOnForbidden: true });
|
|
if (!r || !r.ok) return { nodeName, serverName: server.name, cpuPct: 0, ramPct: 0, diskPct: 0, vmCount: maxVms };
|
|
const st = await r.json();
|
|
return {
|
|
nodeName, serverName: server.name, vmCount: maxVms,
|
|
cpuPct: Math.round((st.cpu || 0) * 100),
|
|
ramPct: st.memory && st.memory.total > 0 ? Math.round((st.memory.used / st.memory.total) * 100) : 0,
|
|
diskPct: st.rootfs && st.rootfs.total > 0 ? Math.round((st.rootfs.used / st.rootfs.total) * 100) : 0,
|
|
};
|
|
} catch (e_) { return { nodeName, serverName: server.name, cpuPct: 0, ramPct: 0, diskPct: 0, vmCount: maxVms }; }
|
|
})),
|
|
Promise.all(uniqueNodes.map(async ({ server, nodeName }) => {
|
|
try {
|
|
const r = await fetchWithAuth(`/proxmox/api/${server.id}/node/rrddata?node=${nodeName}&timeframe=hour`, { hideOnForbidden: true });
|
|
if (!r || !r.ok) return null;
|
|
const d = await r.json();
|
|
return d.data || [];
|
|
} catch (e_) { return null; }
|
|
}))
|
|
]);
|
|
|
|
const allNodeData = statusResults.filter(Boolean);
|
|
|
|
// Aggregate RRD histories
|
|
const cpuHistoryAll = [], ramHistoryAll = [], diskHistoryAll = [];
|
|
for (const data of rrdResults) {
|
|
if (!data || data.length === 0) continue;
|
|
const nc = [], nr = [], nd = [];
|
|
for (const pt of data) {
|
|
nc.push((pt.cpu || 0) * 100);
|
|
nr.push(pt.memtotal > 0 ? ((pt.memused || 0) / pt.memtotal * 100) : (pt.mem || 0) * 100);
|
|
nd.push(pt.roottotal > 0 ? ((pt.rootused || 0) / pt.roottotal * 100) : 0);
|
|
}
|
|
if (nc.length > 0) { cpuHistoryAll.push(nc); ramHistoryAll.push(nr); diskHistoryAll.push(nd); }
|
|
}
|
|
|
|
// ---------- Render resource cards ----------
|
|
if (allNodeData.length > 0) {
|
|
const avgCpu = Math.round(allNodeData.reduce((s, n) => s + n.cpuPct, 0) / allNodeData.length);
|
|
const avgRam = Math.round(allNodeData.reduce((s, n) => s + n.ramPct, 0) / allNodeData.length);
|
|
const avgDisk = Math.round(allNodeData.reduce((s, n) => s + n.diskPct, 0) / allNodeData.length);
|
|
const cpuPctEl = document.getElementById('cpuPct');
|
|
const ramPctEl = document.getElementById('ramPct');
|
|
const diskPctEl = document.getElementById('diskPct');
|
|
if (cpuPctEl) { cpuPctEl.classList.remove('skeleton'); cpuPctEl.textContent = avgCpu + '%'; setPctBadgeColor(cpuPctEl, avgCpu); document.getElementById('cpuBar').style.width = avgCpu + '%'; }
|
|
if (ramPctEl) { ramPctEl.classList.remove('skeleton'); ramPctEl.textContent = avgRam + '%'; setPctBadgeColor(ramPctEl, avgRam); document.getElementById('ramBar').style.width = avgRam + '%'; }
|
|
if (diskPctEl) { diskPctEl.classList.remove('skeleton'); diskPctEl.textContent = avgDisk + '%'; setPctBadgeColor(diskPctEl, avgDisk); document.getElementById('diskBar').style.width = avgDisk + '%'; }
|
|
}
|
|
|
|
// Mini charts
|
|
if (cpuHistoryAll.length > 0) {
|
|
const len = Math.max(...cpuHistoryAll.map(a => a.length));
|
|
const avgH = arrays => Array.from({ length: len }, (_, i) => {
|
|
const vals = arrays.map(a => i < a.length ? a[i] : (a[a.length - 1] || 0));
|
|
return vals.reduce((s, v) => s + v, 0) / vals.length;
|
|
});
|
|
const cpuSlice = avgH(cpuHistoryAll).slice(-24);
|
|
const ramSlice = avgH(ramHistoryAll).slice(-24);
|
|
const diskSlice = avgH(diskHistoryAll).slice(-24);
|
|
requestAnimationFrame(() => {
|
|
drawMiniLineChart('cpuMiniChart', cpuSlice, '#3b82f6', 'rgba(59,130,246,0.15)');
|
|
drawMiniLineChart('ramMiniChart', ramSlice, '#8b5cf6', 'rgba(139,92,246,0.15)');
|
|
if (diskSlice.some(v => v > 0)) {
|
|
drawMiniBarChart('diskMiniChart', diskSlice, 'rgba(16,185,129,0.7)');
|
|
} else {
|
|
const avgD = allNodeData.length > 0 ? Math.round(allNodeData.reduce((s, n) => s + n.diskPct, 0) / allNodeData.length) : 0;
|
|
drawMiniBarChart('diskMiniChart', Array(24).fill(0).map((_, i) => (i / 23) * avgD * 0.9 + avgD * 0.1), 'rgba(16,185,129,0.7)');
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------- Render Nodes panel ----------
|
|
const panel = document.getElementById('nodesPanel');
|
|
const onlineMeta = document.getElementById('nodesOnlineMeta');
|
|
if (onlineMeta) onlineMeta.textContent = allNodeData.length + '/' + allNodeData.length + ' ' + t('online').toLowerCase();
|
|
if (panel) {
|
|
if (allNodeData.length === 0) {
|
|
panel.innerHTML = `<div style="text-align:center;color:var(--text-muted);padding:2rem 1.25rem;font-size:.875rem;">${t('no_nodes')}</div>`;
|
|
} else {
|
|
panel.innerHTML = allNodeData.map(node => `
|
|
<div class="node-item">
|
|
<div class="node-item-header">
|
|
<div class="node-item-name">
|
|
<span class="node-dot-online"></span>
|
|
<span class="node-name-text">${escapeHtml(node.serverName || node.nodeName)}</span>
|
|
</div>
|
|
<span class="node-vm-count">${node.vmCount > 0 ? node.vmCount + ' ' + t('vms') : ''}</span>
|
|
</div>
|
|
<div class="node-bar-row">
|
|
<span class="node-bar-lbl">CPU</span>
|
|
<div class="node-bar-wrap"><div class="node-bar-fill cpu" style="width:${node.cpuPct}%"></div></div>
|
|
<span class="node-bar-pct">${node.cpuPct}%</span>
|
|
</div>
|
|
<div class="node-bar-row">
|
|
<span class="node-bar-lbl">RAM</span>
|
|
<div class="node-bar-wrap"><div class="node-bar-fill ram" style="width:${node.ramPct}%"></div></div>
|
|
<span class="node-bar-pct">${node.ramPct}%</span>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// silent
|
|
}
|
|
}
|
|
|
|
// HTML escape helper
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
const d = document.createElement('div');
|
|
d.textContent = str;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
// Two-speed refresh:
|
|
// • WS push (dashboard_metrics channel) — every 10 s from server
|
|
// • _refreshNodeStats — max once per 60 s (slow, many requests)
|
|
let _lastNodeRefresh = 0;
|
|
const NODE_REFRESH_MIN_MS = 300000; // HTTP fallback every 5 min (WS provides node data normally)
|
|
|
|
async function refreshVMs() {
|
|
showRefreshSpin(true);
|
|
try {
|
|
const res = await fetchWithAuth('/proxmox/api/resources/all', { hideOnForbidden: true });
|
|
if (!res || !res.ok || res.forbidden) { showRefreshSpin(false); return; }
|
|
const data = await res.json();
|
|
renderDashboardData(data);
|
|
} catch (e) {
|
|
// silent
|
|
} finally {
|
|
showRefreshSpin(false);
|
|
}
|
|
}
|
|
|
|
function renderDashboardData(data) {
|
|
showRefreshSpin(true);
|
|
try {
|
|
|
|
let totalVms = 0, runningVms = 0, totalCt = 0, runningCt = 0;
|
|
const allVms = [];
|
|
|
|
for (const server of (data.servers || [])) {
|
|
const vms = server.vms || [];
|
|
const cts = server.containers || [];
|
|
totalVms += vms.length;
|
|
runningVms += vms.filter(v => v.status === 'running').length;
|
|
totalCt += cts.length;
|
|
runningCt += cts.filter(c => c.status === 'running').length;
|
|
for (const vm of vms) allVms.push({ ...vm, serverName: server.name });
|
|
}
|
|
|
|
const vmCount = document.getElementById('statVmCount');
|
|
const vmSub = document.getElementById('statVmSub');
|
|
const ctCount = document.getElementById('statCtCount');
|
|
const ctSub = document.getElementById('statCtSub');
|
|
if (vmCount) { vmCount.classList.remove('skeleton'); vmCount.textContent = totalVms; }
|
|
if (vmSub) vmSub.textContent = runningVms + ' ' + t('running').toLowerCase();
|
|
if (ctCount) { ctCount.classList.remove('skeleton'); ctCount.textContent = totalCt; }
|
|
if (ctSub) ctSub.textContent = runningCt + ' ' + t('running').toLowerCase();
|
|
|
|
const tbody = document.getElementById('vmTableBody');
|
|
const meta = document.getElementById('vmTableMeta');
|
|
if (meta) meta.textContent = totalVms + ' ' + t('total');
|
|
if (tbody) {
|
|
allVms.sort((a, b) => {
|
|
const rank = s => s === 'running' ? 0 : s === 'paused' ? 1 : 2;
|
|
return rank(a.status) - rank(b.status);
|
|
});
|
|
const shown = allVms.slice(0, 8);
|
|
if (shown.length === 0) {
|
|
tbody.innerHTML = `<tr><td colspan="5"><div style="text-align:center;padding:3rem 1.25rem;color:var(--text-muted);"><i class="fa-solid fa-server" style="font-size:2rem;margin-bottom:.75rem;display:block;opacity:.3;"></i><div style="font-size:.875rem;">${t('no_vms_found')}</div></div></td></tr>`;
|
|
} else {
|
|
tbody.innerHTML = shown.map(vm => {
|
|
const cpuPct = vm.cpu != null ? (vm.cpu * 100).toFixed(0) + '%' : '—';
|
|
const ramGb = vm.mem != null ? (vm.mem / (1024**3)).toFixed(1) + 'G' : '—';
|
|
const status = (vm.status || 'unknown').toLowerCase();
|
|
const statusCls = status === 'running' ? 'running' : status === 'paused' ? 'warning' : 'stopped';
|
|
const statusLabel = status === 'running' ? t('running') : status === 'paused' ? t('paused') : t('stopped');
|
|
return `<tr>
|
|
<td><span class="vm-name-cell">${escapeHtml(vm.name || 'vm-' + vm.vmid)}</span></td>
|
|
<td><span class="vm-node-cell">${escapeHtml(vm.node || '—')}</span></td>
|
|
<td><span class="vm-metric-cell">${cpuPct}</span></td>
|
|
<td><span class="vm-metric-cell">${ramGb}</span></td>
|
|
<td><span class="vm-status-badge ${statusCls}"><span class="status-dot-sm"></span>${statusLabel}</span></td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
// Use node data from WS payload (cpu_pct/ram_pct/disk_pct/rrd_*) if available
|
|
const allNodes = [];
|
|
for (const server of (data.servers || [])) {
|
|
if (Array.isArray(server.nodes)) {
|
|
for (const node of server.nodes) allNodes.push({ ...node, serverName: server.name });
|
|
}
|
|
}
|
|
if (allNodes.length > 0) {
|
|
const avgCpu = Math.round(allNodes.reduce((s, n) => s + (n.cpu_pct || 0), 0) / allNodes.length);
|
|
const avgRam = Math.round(allNodes.reduce((s, n) => s + (n.ram_pct || 0), 0) / allNodes.length);
|
|
const avgDisk = Math.round(allNodes.reduce((s, n) => s + (n.disk_pct || 0), 0) / allNodes.length);
|
|
const cpuPctEl = document.getElementById('cpuPct');
|
|
const ramPctEl = document.getElementById('ramPct');
|
|
const diskPctEl = document.getElementById('diskPct');
|
|
if (cpuPctEl) { cpuPctEl.classList.remove('skeleton'); cpuPctEl.textContent = avgCpu + '%'; setPctBadgeColor(cpuPctEl, avgCpu); document.getElementById('cpuBar').style.width = avgCpu + '%'; }
|
|
if (ramPctEl) { ramPctEl.classList.remove('skeleton'); ramPctEl.textContent = avgRam + '%'; setPctBadgeColor(ramPctEl, avgRam); document.getElementById('ramBar').style.width = avgRam + '%'; }
|
|
if (diskPctEl) { diskPctEl.classList.remove('skeleton'); diskPctEl.textContent = avgDisk + '%'; setPctBadgeColor(diskPctEl, avgDisk); document.getElementById('diskBar').style.width = avgDisk + '%'; }
|
|
|
|
// Mini charts from WS-provided RRD slices
|
|
const cpuHistoryAll = [], ramHistoryAll = [], diskHistoryAll = [];
|
|
for (const node of allNodes) {
|
|
if (Array.isArray(node.rrd_cpu) && node.rrd_cpu.length > 0) {
|
|
cpuHistoryAll.push(node.rrd_cpu);
|
|
ramHistoryAll.push(node.rrd_ram || []);
|
|
diskHistoryAll.push(node.rrd_disk || []);
|
|
}
|
|
}
|
|
if (cpuHistoryAll.length > 0) {
|
|
const len = Math.max(...cpuHistoryAll.map(a => a.length));
|
|
const avgH = arrays => Array.from({ length: len }, (_, i) => {
|
|
const vals = arrays.map(a => i < a.length ? a[i] : (a[a.length - 1] || 0));
|
|
return vals.reduce((s, v) => s + v, 0) / vals.length;
|
|
});
|
|
const cpuSlice = avgH(cpuHistoryAll);
|
|
const ramSlice = avgH(ramHistoryAll);
|
|
const diskSlice = avgH(diskHistoryAll);
|
|
requestAnimationFrame(() => {
|
|
drawMiniLineChart('cpuMiniChart', cpuSlice, '#3b82f6', 'rgba(59,130,246,0.15)');
|
|
drawMiniLineChart('ramMiniChart', ramSlice, '#8b5cf6', 'rgba(139,92,246,0.15)');
|
|
if (diskSlice.some(v => v > 0)) {
|
|
drawMiniBarChart('diskMiniChart', diskSlice, 'rgba(16,185,129,0.7)');
|
|
} else {
|
|
drawMiniBarChart('diskMiniChart', Array(24).fill(0).map((_, i) => (i / 23) * avgDisk * 0.9 + avgDisk * 0.1), 'rgba(16,185,129,0.7)');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Nodes panel
|
|
const panel = document.getElementById('nodesPanel');
|
|
const onlineMeta = document.getElementById('nodesOnlineMeta');
|
|
if (onlineMeta) onlineMeta.textContent = allNodes.length + '/' + allNodes.length + ' ' + t('online').toLowerCase();
|
|
if (panel) {
|
|
panel.innerHTML = allNodes.map(node => `
|
|
<div class="node-item">
|
|
<div class="node-item-header">
|
|
<div class="node-item-name">
|
|
<span class="node-dot-online"></span>
|
|
<span class="node-name-text">${escapeHtml(node.serverName || node.name)}</span>
|
|
</div>
|
|
</div>
|
|
<div class="node-bar-row">
|
|
<span class="node-bar-lbl">CPU</span>
|
|
<div class="node-bar-wrap"><div class="node-bar-fill cpu" style="width:${node.cpu_pct}%"></div></div>
|
|
<span class="node-bar-pct">${node.cpu_pct}%</span>
|
|
</div>
|
|
<div class="node-bar-row">
|
|
<span class="node-bar-lbl">RAM</span>
|
|
<div class="node-bar-wrap"><div class="node-bar-fill ram" style="width:${node.ram_pct}%"></div></div>
|
|
<span class="node-bar-pct">${node.ram_pct}%</span>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
_lastNodeRefresh = Date.now();
|
|
} else {
|
|
// Fallback: HTTP poll if WS has no node data yet
|
|
const now = Date.now();
|
|
if (now - _lastNodeRefresh >= NODE_REFRESH_MIN_MS) {
|
|
_lastNodeRefresh = now;
|
|
const onlineServers = (data.servers || []).filter(s => !s.error && s.id);
|
|
if (onlineServers.length > 0) {
|
|
_refreshNodeStats(onlineServers).then(() => { markLastUpdated(); });
|
|
}
|
|
}
|
|
}
|
|
|
|
markLastUpdated();
|
|
} catch (e) {
|
|
// silent
|
|
} finally {
|
|
showRefreshSpin(false);
|
|
}
|
|
}
|
|
|
|
// Init — loads initial data then subscribes to WebSocket push updates
|
|
document.addEventListener('DOMContentLoaded', async function() {
|
|
// Initial full load (VMs + nodes)
|
|
showRefreshSpin(true);
|
|
try {
|
|
const res = await fetchWithAuth('/proxmox/api/resources/all', { hideOnForbidden: true });
|
|
if (res && res.ok && !res.forbidden) {
|
|
const data = await res.json();
|
|
const onlineServers = (data.servers || []).filter(s => !s.error && s.id);
|
|
if (onlineServers.length > 0) {
|
|
_lastNodeRefresh = Date.now();
|
|
await _refreshNodeStats(onlineServers);
|
|
}
|
|
}
|
|
} catch (e) {/* ignore */}
|
|
await refreshVMs();
|
|
|
|
// Subscribe to WebSocket push updates.
|
|
// subscribeWsChannel queues the subscription and sends it on WS open/reconnect.
|
|
if (typeof subscribeWsChannel === 'function') {
|
|
subscribeWsChannel('dashboard_metrics');
|
|
}
|
|
if (typeof registerWsHandler === 'function') {
|
|
registerWsHandler('metrics_update', (msg) => {
|
|
if (msg.channel === 'dashboard_metrics') {
|
|
renderDashboardData(msg.data);
|
|
}
|
|
});
|
|
}
|
|
window.addEventListener('beforeunload', () => {
|
|
if (typeof unsubscribeWsChannel === 'function') unsubscribeWsChannel('dashboard_metrics');
|
|
});
|
|
});
|
|
|
|
// ======== Alerts Modal ========
|
|
function closeAlertsModal() {
|
|
document.getElementById('alertsModal').style.display = 'none';
|
|
}
|
|
|
|
async function openAlertsModal() {
|
|
const modal = document.getElementById('alertsModal');
|
|
modal.style.display = 'flex';
|
|
const body = document.getElementById('alertsModalBody');
|
|
body.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:2rem;"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
|
|
try {
|
|
const res = await fetchWithAuth('/api/dashboard/alerts');
|
|
if (!res || !res.ok) throw new Error('failed');
|
|
const data = await res.json();
|
|
if (!data.alerts || data.alerts.length === 0) {
|
|
body.innerHTML = `<div style="text-align:center;color:var(--text-muted);padding:2rem;">${t('no_alerts')}</div>`;
|
|
return;
|
|
}
|
|
const locale = '{{ "ru-RU" if lang == "ru" else "en-US" }}';
|
|
body.innerHTML = data.alerts.map(a => {
|
|
const time = a.created_at
|
|
? new Date(a.created_at).toLocaleString(locale, {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'})
|
|
: '—';
|
|
const lvlCls = a.level === 'critical' ? 'critical' : 'error';
|
|
return `<div class="audit-row" style="align-items:flex-start;padding:.65rem 0;border-bottom:1px solid var(--border, rgba(255,255,255,.06));">
|
|
<span class="audit-time" style="flex-shrink:0;">${time}</span>
|
|
<span style="flex-shrink:0;"><span class="audit-action-tag ${lvlCls}">${escapeHtml(a.level)}</span></span>
|
|
<span style="flex:1;font-size:.875rem;color:var(--text-primary);word-break:break-word;">${escapeHtml(a.message)}</span>
|
|
${a.username ? `<span class="audit-user" style="flex-shrink:0;">${escapeHtml(a.username)}</span>` : '<span></span>'}
|
|
</div>`;
|
|
}).join('');
|
|
} catch (e) {
|
|
body.innerHTML = '<div style="text-align:center;color:var(--danger);padding:2rem;"><i class="fa-solid fa-circle-exclamation" style="margin-right:.5rem;"></i>Ошибка загрузки</div>';
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<!-- ======== Alerts Modal ======== -->
|
|
<div id="alertsModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.82);z-index:2000;align-items:center;justify-content:center;">
|
|
<div class="card" style="max-width:680px;width:95%;max-height:82vh;display:flex;flex-direction:column;">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
|
<h3 style="margin:0;color:var(--text-primary);">
|
|
<i class="fa-solid fa-triangle-exclamation" style="color:var(--warning,#f59e0b);margin-right:.5rem;"></i>
|
|
{{ t('alerts_label') }} — {{ t('last_24h') }}
|
|
</h3>
|
|
<button onclick="closeAlertsModal()" aria-label="Закрыть" class="modal-close-btn">×</button>
|
|
</div>
|
|
<div id="alertsModalBody" style="overflow-y:auto;flex:1;min-height:80px;"></div>
|
|
<div style="margin-top:.75rem;padding-top:.75rem;border-top:1px solid var(--border, rgba(255,255,255,.06));text-align:right;">
|
|
<a href="/logs?level=error" class="btn btn-secondary" style="text-decoration:none;">
|
|
<i class="fa-solid fa-arrow-right" style="margin-right:.5rem;"></i>{{ t('view_all') }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
document.getElementById('alertsModal').addEventListener('click', function(e) {
|
|
if (e.target === this) closeAlertsModal();
|
|
});
|
|
</script>
|
|
{% endblock %}
|