feat: v1.1.2 — dashboard redesign, pve CLI tool, sidebar cluster selector

This commit is contained in:
markmorado
2026-03-08 20:35:43 +01:00
parent b8c6e52c9a
commit d788ac78c3
19 changed files with 1764 additions and 352 deletions

View File

@@ -4,14 +4,12 @@
{% block content %}
<div class="page-container">
<div class="page-header d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="page-title mb-0">
<i class="fa-solid fa-hard-drive me-2"></i>
{{ t('nav_backups') }}
</h1>
<div class="page-header-block">
<div class="page-header-left">
<nav class="breadcrumb-nav">{{ t('dashboard') }} / {{ t('nav_backups') }}</nav>
<h1 class="page-header-title"><i class="fa-solid fa-hard-drive"></i> {{ t('nav_backups') }}</h1>
</div>
<div class="d-flex gap-2">
<div class="page-header-right">
<select id="serverSelect" class="form-select form-select-sm" style="min-width:180px" onchange="onServerChange()">
<option value="">{{ t('select_server') }}</option>
{% for s in servers %}

View File

@@ -544,6 +544,14 @@ body.update-in-progress .layout {
<span style="font-size: 2rem;"><i class="fa-solid fa-cloud"></i></span>
{% endif %}
</div>
<!-- Cluster Selector -->
<div class="sidebar-cluster-section" id="sidebarClusterSection">
<div class="sidebar-cluster-label">Cluster</div>
<button class="cluster-selector-btn" id="sidebarClusterBtn" title="Active cluster">
<span class="cluster-name-text" id="sidebarClusterName">—</span>
<i class="fa-solid fa-chevron-down" style="font-size:0.65rem; opacity:0.6;"></i>
</button>
</div>
<nav>
<ul class="sidebar-nav">
<li><a href="/" class="{{ 'active' if request.path == '/' or request.path == '/dashboard' else '' }}" title="{{ t('nav_dashboard') }}">
@@ -618,8 +626,15 @@ body.update-in-progress .layout {
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<h2>{{ page_title or title or t('nav_dashboard') }}</h2>
<div class="header-title-block">
<h2>{{ page_title or title or t('nav_dashboard') }}</h2>
{% block page_subtitle %}<div class="page-subtitle" id="pageGlobalSubtitle"></div>{% endblock %}
</div>
<div class="header-actions">
{% block header_status %}{% endblock %}
<!-- Version Badge -->
<span class="version-badge" title="PVEmanager version">v{{ version }}</span>
<!-- GitHub Stars Button -->
<a href="https://git.tzim.uz/markmorado/pvemanager" target="_blank" rel="noopener noreferrer" class="github-btn" title="Star us on Gitea">
<i class="fa-brands fa-github"></i>
@@ -931,7 +946,31 @@ body.update-in-progress .layout {
}
}
}
// Load cluster info for sidebar cluster selector
async function loadSidebarCluster() {
try {
const response = await fetchWithAuth('/proxmox/api/servers', { hideOnForbidden: true });
if (!response || !response.ok) return;
const servers = await response.json();
const nameEl = document.getElementById('sidebarClusterName');
if (!nameEl) return;
if (!servers || servers.length === 0) {
nameEl.textContent = 'No cluster';
return;
}
// Find a named cluster
const named = servers.find(s => s.cluster_name && s.cluster_name.trim() !== '');
if (named) {
nameEl.textContent = named.cluster_name;
} else {
nameEl.textContent = servers[0].name || 'Standalone';
}
} catch (e) {
// silently ignore
}
}
// Load unread notification count
async function loadNotificationCount() {
try {
@@ -1355,6 +1394,9 @@ body.update-in-progress .layout {
// Init task drawer WS
initTaskDrawerWS();
// Load cluster info for sidebar
loadSidebarCluster();
}
// Add click handlers for sidebar menu items to update page title

View File

@@ -1,281 +1,530 @@
{% 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 header_status %}
<div class="status-chip {% if stats.total_alerts == 0 %}status-ok{% elif stats.critical_alerts > 0 %}status-error{% else %}status-warning{% endif %}" id="dashStatusChip">
<span class="status-chip-dot"></span>
<span id="dashStatusText">
{%- if stats.total_alerts == 0 -%}
All systems operational
{%- elif stats.critical_alerts > 0 -%}
{{ stats.critical_alerts }} critical alert{{ 's' if stats.critical_alerts != 1 else '' }}
{%- else -%}
{{ stats.total_alerts }} warning{{ 's' if stats.total_alerts != 1 else '' }}
{%- endif -%}
</span>
</div>
<div class="header-datetime" id="dashDatetime"></div>
{% endblock %}
{% block content %}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">{{ t('proxmox_servers') }}</div>
<div class="stat-value">{{ stats.total_servers }}</div>
<!-- ======== 5 STAT CARDS ======== -->
<div class="stats-grid-5">
<div class="stat-card-v2">
<div class="stat-card-lbl">Nodes</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 -%}
All online
{%- elif stats.online_servers > 0 -%}
{{ stats.online_servers }} online
{%- else -%}
—
{%- endif -%}
</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t('online') }}</div>
<div class="stat-value text-success">{{ stats.online_servers }}</div>
<div class="stat-card-v2">
<div class="stat-card-lbl">VMs</div>
<div class="stat-card-val" id="statVmCount">—</div>
<div class="stat-card-sub" id="statVmSub">loading…</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t('offline') }}</div>
<div class="stat-value text-danger">{{ stats.offline_servers }}</div>
<div class="stat-card-v2">
<div class="stat-card-lbl">Containers</div>
<div class="stat-card-val" id="statCtCount">—</div>
<div class="stat-card-sub" id="statCtSub">loading…</div>
</div>
<div class="stat-card-v2">
<div class="stat-card-lbl">Clusters</div>
<div class="stat-card-val">{{ stats.total_clusters }}</div>
<div class="stat-card-sub" id="statClusterSub">
{%- if stats.total_alerts == 0 -%}All healthy{%- else -%}—{%- endif -%}
</div>
</div>
<div class="stat-card-v2 {% if stats.critical_alerts > 0 %}card-alerts{% endif %}">
<div class="stat-card-lbl">Alerts</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 }} critical</span>
{%- elif stats.total_alerts > 0 -%}
last 24h
{%- else -%}
No alerts
{%- endif -%}
</div>
</div>
</div>
<!-- Графики Proxmox нод -->
<div class="card" id="proxmox-charts-section" style="display: none;">
<h3><i class="fa-solid fa-chart-line"></i> {{ t('proxmox_nodes_load') }}</h3>
<div class="dashboard-charts-grid" id="nodes-charts-grid"></div>
<!-- ======== RESOURCE CARDS (CPU / RAM / STORAGE) ======== -->
<div class="resource-cards-row" id="resourceCardsRow">
<div class="resource-card">
<div class="resource-card-header">
<span class="resource-card-title">CPU Usage</span>
<span class="resource-pct-badge" 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">RAM Usage</span>
<span class="resource-pct-badge" 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">Storage</span>
<span class="resource-pct-badge" 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>
<style>
.dashboard-charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
@media (max-width: 768px) {
.dashboard-charts-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: 1fr !important;
}
.stat-card {
padding: 1rem;
}
.stat-value {
font-size: 1.75rem;
}
}
@media (max-width: 480px) {
.dashboard-charts-grid .card h4 {
font-size: 0.9rem;
}
}
</style>
<!-- ======== 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">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">View all →</a>
</div>
</div>
<div style="overflow-x: auto;">
<table class="vm-table-compact">
<thead>
<tr>
<th>Name</th>
<th>Node</th>
<th>CPU</th>
<th>RAM</th>
<th>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> Loading…
</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Nodes Panel -->
<div class="dash-section">
<div class="dash-section-header">
<span class="dash-section-title">Nodes</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> 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">Recent Audit Events</span>
<a href="/logs" class="dash-section-link">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;">
No audit events found
</div>
{% endif %}
</div>
</div>
<script>
// Загрузка статистики и графиков Proxmox нод
async function loadProxmoxNodesStats() {
try {
const response = await fetchWithAuth('/proxmox/api/servers');
if (!response) return;
const servers = await response.json();
const onlineServers = servers.filter(s => s.is_online);
if (onlineServers.length === 0) return;
const chartsSection = document.getElementById('proxmox-charts-section');
const chartsGrid = document.getElementById('nodes-charts-grid');
chartsGrid.innerHTML = '';
// Собрать все данные сначала, потом отрисовать
const chartsToRender = [];
// Дедупликация по ключу "cluster_id::node".
// Члены одного кластера возвращают одинаковый cluster_id (имя кластера),
// поэтому общие ноды показываются один раз.
// Независимые серверы с одинаковым именем ноды получают разные cluster_id
// ("standalone-{server_id}"), поэтому отображаются отдельно.
const seenNodes = new Set();
// Загрузить данные для каждого сервера
for (const server of onlineServers) {
try {
// Получить список нод
const nodesResponse = await fetchWithAuth(`/proxmox/api/${server.id}/nodes`);
if (!nodesResponse || !nodesResponse.ok) {
console.warn(`Failed to load nodes for ${server.name}`);
continue;
}
const nodesData = await nodesResponse.json();
const nodes = nodesData.nodes || [];
const clusterId = nodesData.cluster_id || `standalone-${server.id}`;
// ======== Dashboard JS ========
if (nodes.length === 0) {
console.warn(`No nodes found for ${server.name}`);
continue;
}
// Обработать ВСЕ ноды этого сервера
for (const nodeInfo of nodes) {
const node = nodeInfo.node;
// Дедупликация по кластеру: члены одного кластера имеют
// одинаковый clusterId и одинаковые имена нод → пропускаем дубли.
// Независимые серверы с одинаковым именем ноды имеют разные
// clusterId → отображаются отдельно.
const dedupeKey = `${clusterId}::${node}`;
if (seenNodes.has(dedupeKey)) {
continue;
}
seenNodes.add(dedupeKey);
// Получить данные для графиков
const rrdResponse = await fetchWithAuth(`/proxmox/api/${server.id}/node/rrddata?node=${node}&timeframe=hour`);
if (!rrdResponse || !rrdResponse.ok) {
console.warn(`Failed to load RRD data for ${server.name}/${node}, skipping chart`);
continue;
}
const rrdData = await rrdResponse.json();
const data = rrdData.data || [];
if (data.length === 0) {
console.warn(`No RRD data for ${server.name}/${node}`);
continue;
}
// Сохранить данные для отрисовки
chartsToRender.push({
canvasId: `chart-${server.id}-${node}`,
serverName: server.name,
nodeName: node,
data: data
});
}
} catch (error) {
console.error(`Error loading stats for server ${server.id}:`, error);
}
}
// Теперь создать все canvas элементы
if (chartsToRender.length > 0) {
let html = '';
for (const chart of chartsToRender) {
// Извлекаем базовое имя сервера (убираем " - nodeX" если есть)
let baseServerName = chart.serverName.trim();
// Если имя сервера заканчивается на " - <nodeName>", убираем это
const nodePattern = new RegExp(`\\s*-\\s*(pve\\d+|node\\d+|${chart.nodeName})$`, 'i');
baseServerName = baseServerName.replace(nodePattern, '').trim();
// Формируем отображаемое имя
// Если базовое имя пустое или равно имени ноды — просто показываем имя ноды
let displayName;
if (!baseServerName || baseServerName.toLowerCase() === chart.nodeName.toLowerCase()) {
displayName = chart.nodeName;
} else {
displayName = `${baseServerName} - ${chart.nodeName}`;
}
html += `
<div class="card">
<h4 style="margin: 0 0 1rem 0;">${displayName}</h4>
<canvas id="${chart.canvasId}" style="max-height: 250px;"></canvas>
</div>
`;
}
chartsGrid.innerHTML = html;
chartsSection.style.display = 'block';
// Отрисовать все графики после того как DOM обновился
requestAnimationFrame(() => {
for (const chart of chartsToRender) {
drawNodeChart(chart.canvasId, chart.data);
}
});
}
} catch (error) {
console.error('Error loading Proxmox stats:', error);
// Live clock
(function startClock() {
const el = document.getElementById('dashDatetime');
if (!el) return;
function tick() {
const now = new Date();
el.textContent = now.toLocaleDateString(
'{{ "ru-RU" if lang == "ru" else "en-US" }}',
{ month: 'short', day: 'numeric' }
) + ', ' + now.toLocaleTimeString(
'{{ "ru-RU" if lang == "ru" else "en-US" }}',
{ hour: '2-digit', minute: '2-digit' }
);
}
tick();
setInterval(tick, 30000);
})();
// 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');
}
// Отрисовка графика ноды
function drawNodeChart(canvasId, data) {
if (typeof Chart === 'undefined') {
console.error('Chart.js not loaded');
return;
}
const ctx = document.getElementById(canvasId);
if (!ctx) {
console.error('Canvas not found:', canvasId);
return;
}
const locale = "{{ 'ru-RU' if lang == 'ru' else 'en-US' }}";
const formatTime = (timestamp) => {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
};
new Chart(ctx, {
// Draw a mini line chart on a canvas
function drawMiniLineChart(canvasId, dataPoints, color, bgColor) {
if (typeof Chart === 'undefined') return;
const canvas = document.getElementById(canvasId);
if (!canvas) return;
// destroy existing
if (canvas._chartInstance) { canvas._chartInstance.destroy(); }
const ctx = canvas.getContext('2d');
canvas._chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: data.map(d => formatTime(d.time)),
datasets: [
{
label: '{{ t('cpu') }} %',
data: data.map(d => ((d.cpu || 0) * 100).toFixed(2)),
borderColor: '#60a5fa',
backgroundColor: 'rgba(96,165,250,0.1)',
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 0,
yAxisID: 'y'
},
{
label: '{{ t('memory') }} {{ t('gb') }}',
data: data.map(d => ((d.memused || 0) / (1024*1024*1024)).toFixed(2)),
borderColor: '#f59e0b',
backgroundColor: 'rgba(245,158,11,0.1)',
borderWidth: 2,
tension: 0.4,
fill: true,
pointRadius: 0,
yAxisID: 'y1'
}
]
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: true,
plugins: {
legend: {
display: true,
position: 'top',
labels: { color: '#9ca3af', usePointStyle: true, padding: 10 }
},
tooltip: { enabled: true, mode: 'index', intersect: false }
},
maintainAspectRatio: false,
animation: { duration: 600 },
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: {
x: {
type: 'category',
grid: { color: 'rgba(255,255,255,0.1)' },
ticks: { color: '#9ca3af', maxTicksLimit: 10 }
},
y: {
type: 'linear',
display: true,
position: 'left',
beginAtZero: true,
grid: { color: 'rgba(255,255,255,0.1)' },
ticks: { color: '#9ca3af' },
title: { display: true, text: '{{ t('cpu') }} %', color: '#60a5fa' }
},
y1: {
type: 'linear',
display: true,
position: 'right',
beginAtZero: true,
grid: { drawOnChartArea: false },
ticks: { color: '#9ca3af' },
title: { display: true, text: '{{ t('memory') }} {{ t('gb') }}', color: '#f59e0b' }
}
x: { display: false },
y: { display: false, beginAtZero: true, max: 100 }
}
}
});
}
// Инициализация при полной загрузке DOM
// Draw a mini bar chart (for storage)
function drawMiniBarChart(canvasId, dataPoints, color) {
if (typeof Chart === 'undefined') return;
const canvas = document.getElementById(canvasId);
if (!canvas) return;
if (canvas._chartInstance) { canvas._chartInstance.destroy(); }
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: 600 },
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: {
x: { display: false },
y: { display: false, beginAtZero: true, max: 100 }
}
}
});
}
// Load all resources to populate VMs stat card and VM table
async function loadVMStats() {
try {
const res = await fetchWithAuth('/proxmox/api/resources/all', { hideOnForbidden: true });
if (!res || !res.ok || res.forbidden) return;
const data = await res.json();
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 });
}
}
// Update stat cards
const vmCount = document.getElementById('statVmCount');
const vmSub = document.getElementById('statVmSub');
const ctCount = document.getElementById('statCtCount');
const ctSub = document.getElementById('statCtSub');
if (vmCount) vmCount.textContent = totalVms;
if (vmSub) vmSub.textContent = runningVms + ' running';
if (ctCount) ctCount.textContent = totalCt;
if (ctSub) ctSub.textContent = runningCt + ' running';
// Update VM table (show max 8 — sorted: running first)
const tbody = document.getElementById('vmTableBody');
const meta = document.getElementById('vmTableMeta');
if (meta) meta.textContent = totalVms + ' 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" style="text-align:center; color:var(--text-muted); padding:2rem 1.25rem;">No VMs found</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.charAt(0).toUpperCase() + status.slice(1);
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('');
}
}
} catch (e) {
console.error('VM stats error:', e);
}
}
// Load node data and resource charts + nodes panel
async function loadNodeStats() {
try {
const serversRes = await fetchWithAuth('/proxmox/api/servers', { hideOnForbidden: true });
if (!serversRes || !serversRes.ok || serversRes.forbidden) return;
const servers = await serversRes.json();
const onlineServers = servers.filter(s => s.is_online);
if (onlineServers.length === 0) return;
const seenNodes = new Set();
const allNodeData = [];
const cpuHistory = [], ramHistory = [], diskHistory = [];
for (const server of onlineServers) {
try {
const nodesRes = await fetchWithAuth(`/proxmox/api/${server.id}/nodes`, { hideOnForbidden: true });
if (!nodesRes || !nodesRes.ok) continue;
const nodesData = await nodesRes.json();
const nodes = nodesData.nodes || [];
const clusterId = nodesData.cluster_id || `standalone-${server.id}`;
for (const nodeInfo of nodes) {
const nodeName = nodeInfo.node;
const key = `${clusterId}::${nodeName}`;
if (seenNodes.has(key)) continue;
seenNodes.add(key);
// Get node status (cpu, mem)
try {
const statusRes = await fetchWithAuth(
`/proxmox/api/${server.id}/node/status?node=${nodeName}`,
{ hideOnForbidden: true }
);
if (statusRes && statusRes.ok) {
const st = await statusRes.json();
const cpuPct = Math.round((st.cpu || 0) * 100);
const ramPct = st.memory && st.memory.total > 0
? Math.round((st.memory.used / st.memory.total) * 100)
: 0;
allNodeData.push({ name: nodeName, cpuPct, ramPct, vmCount: nodeInfo.maxvms || 0 });
}
} catch (e) {
allNodeData.push({ name: nodeName, cpuPct: 0, ramPct: 0, vmCount: 0 });
}
// Load RRD for charts (only first node for aggregate chart)
if (cpuHistory.length === 0) {
try {
const rrdRes = await fetchWithAuth(
`/proxmox/api/${server.id}/node/rrddata?node=${nodeName}&timeframe=hour`,
{ hideOnForbidden: true }
);
if (rrdRes && rrdRes.ok) {
const rrd = await rrdRes.json();
const data = rrd.data || [];
for (const pt of data) {
cpuHistory.push(((pt.cpu || 0) * 100));
const rPct = pt.memtotal > 0
? ((pt.memused || 0) / pt.memtotal * 100)
: (pt.mem || 0) * 100;
ramHistory.push(rPct);
const dPct = pt.roottotal > 0
? ((pt.rootused || 0) / pt.roottotal * 100)
: 0;
diskHistory.push(dPct);
}
}
} catch (e) { /* ignore */ }
}
}
} catch (e) {
console.error('Node load error:', e);
}
}
// === Render resource cards ===
if (allNodeData.length > 0) {
// Aggregate avg
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 cpuPctEl = document.getElementById('cpuPct');
const ramPctEl = document.getElementById('ramPct');
const cpuBarEl = document.getElementById('cpuBar');
const ramBarEl = document.getElementById('ramBar');
if (cpuPctEl) { cpuPctEl.textContent = avgCpu + '%'; setPctBadgeColor(cpuPctEl, avgCpu); }
if (ramPctEl) { ramPctEl.textContent = avgRam + '%'; setPctBadgeColor(ramPctEl, avgRam); }
if (cpuBarEl) cpuBarEl.style.width = avgCpu + '%';
if (ramBarEl) ramBarEl.style.width = avgRam + '%';
}
// Draw mini charts
if (cpuHistory.length > 0) {
const cpuSlice = cpuHistory.slice(-24);
const ramSlice = ramHistory.slice(-24);
const diskSlice = diskHistory.slice(-24);
const lastDisk = diskSlice.length > 0 ? diskSlice[diskSlice.length - 1] : 0;
const pctDisk = Math.round(lastDisk);
const diskPctEl = document.getElementById('diskPct');
const diskBarEl = document.getElementById('diskBar');
if (diskPctEl) { diskPctEl.textContent = pctDisk + '%'; setPctBadgeColor(diskPctEl, pctDisk); }
if (diskBarEl) diskBarEl.style.width = pctDisk + '%';
requestAnimationFrame(() => {
drawMiniLineChart('cpuMiniChart', cpuSlice, '#3b82f6', 'rgba(59,130,246,0.15)');
drawMiniLineChart('ramMiniChart', ramSlice, '#8b5cf6', 'rgba(139,92,246,0.15)');
// disk as bar chart
if (diskSlice.some(v => v > 0)) {
drawMiniBarChart('diskMiniChart', diskSlice, 'rgba(16,185,129,0.7)');
} else {
// fallback: simple bar from pct
const fakeData = Array(24).fill(0).map((_, i) => (i / 23) * pctDisk * 0.9 + pctDisk * 0.1);
drawMiniBarChart('diskMiniChart', fakeData, '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 + ' online';
if (panel) {
if (allNodeData.length === 0) {
panel.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:2rem 1.25rem;font-size:.875rem;">No nodes available</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>
${escapeHtml(node.name)}
</div>
<span class="node-vm-count">${node.vmCount > 0 ? node.vmCount + ' 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) {
console.error('Node stats error:', e);
}
}
// HTML escape helper
function escapeHtml(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
// Init
document.addEventListener('DOMContentLoaded', function() {
// Загрузить графики нод
loadProxmoxNodesStats();
loadVMStats();
loadNodeStats();
});
</script>
{% endblock %}

View File

@@ -2,15 +2,16 @@
{% block title %}IPAM - {{ t('ip_address_management') }}{% endblock %}
{% block content %}
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 600; margin: 0;"><i class="fa-solid fa-globe"></i> IPAM</h1>
<p style="color: var(--text-secondary); margin-top: 0.25rem;">{{ t('ip_address_management') }}</p>
<div class="page-header-block">
<div class="page-header-left">
<nav class="breadcrumb-nav">{{ t('dashboard') }} / IPAM</nav>
<h1 class="page-header-title"><i class="fa-solid fa-globe"></i> IPAM</h1>
<p class="page-header-sub">{{ t('ip_address_management') }}</p>
</div>
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
<div class="page-header-right" style="flex-wrap: wrap;">
<a href="/ipam/networks" class="btn btn-secondary"><i class="fa-solid fa-list"></i> {{ t('networks') }}</a>
<a href="/ipam/allocations" class="btn btn-secondary"><i class="fa-solid fa-thumbtack"></i> {{ t('allocations') }}</a>
<button onclick="showMaintenanceModal()" class="btn btn-warning" data-permission="ipam.manage" title="Обслуживание IPAM"><i class="fa-solid fa-wrench"></i> {{ t('maintenance') }}</button>
<button onclick="showMaintenanceModal()" class="btn btn-warning" data-permission="ipam.manage"><i class="fa-solid fa-wrench"></i> {{ t('maintenance') }}</button>
<button onclick="showAddNetworkModal()" class="btn btn-success" data-permission="ipam.manage"><i class="fa-solid fa-plus"></i> {{ t('add_network') }}</button>
</div>
</div>

View File

@@ -47,7 +47,10 @@
.login-header h1 {
margin: 0 0 0.5rem 0;
font-size: 1.75rem;
color: #22c55e;
background: linear-gradient(135deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.login-header p {
margin: 0;
@@ -77,14 +80,14 @@
}
.form-group input:focus {
outline: none;
border-color: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.1);
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
}
.btn {
width: 100%;
padding: 0.875rem 1rem;
background: #22c55e;
color: #020617;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
@@ -95,7 +98,8 @@
touch-action: manipulation;
}
.btn:hover {
background: #16a34a;
background: linear-gradient(135deg, #5a6fd6, #6a3f96);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.btn:disabled {
opacity: 0.5;

View File

@@ -386,14 +386,13 @@
}
</style>
<div class="logs-header">
<div>
<h3 style="margin: 0; color: var(--text-primary);">{{ t('event_log') }}</h3>
<p style="margin: 0.25rem 0 0; color: var(--text-muted); font-size: 0.875rem;">
{{ t('view_and_filter_system_logs') }}
</p>
<div class="page-header-block">
<div class="page-header-left">
<nav class="breadcrumb-nav">{{ t('dashboard') }} / {{ t('nav_logs') }}</nav>
<h1 class="page-header-title"><i class="fa-solid fa-clipboard-list"></i> {{ t('event_log') }}</h1>
<p class="page-header-sub">{{ t('view_and_filter_system_logs') }}</p>
</div>
<div style="display: flex; gap: 0.5rem;">
<div class="page-header-right">
<button class="btn btn-secondary" onclick="refreshLogs()"><i class="fa-solid fa-rotate"></i> {{ t('refresh') }}</button>
<button class="btn btn-secondary" onclick="exportLogs()"><i class="fa-solid fa-calendar"></i> {{ t('export') }}</button>
</div>

View File

@@ -14,19 +14,17 @@
}
</style>
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3><i class="fa-solid fa-desktop"></i> {{ t('os_templates') }}</h3>
<div style="display: flex; gap: 0.5rem;" data-permission="templates.manage">
<button onclick="showAutoImportModal()" class="btn" style="background: var(--success); color: var(--text-on-success);"><i class="fa-solid fa-rotate"></i> {{ t('auto_import') }}</button>
<button onclick="showAddGroupModal()" class="btn" style="background: var(--primary); color: var(--text-on-primary);"><i class="fa-solid fa-folder"></i> {{ t('new_group') }}</button>
<button onclick="showAddTemplateModal()" class="btn btn-primary"><i class="fa-solid fa-plus"></i> {{ t('add_template') }}</button>
</div>
<div class="page-header-block">
<div class="page-header-left">
<nav class="breadcrumb-nav">{{ t('dashboard') }} / {{ t('os_templates_management') }}</nav>
<h1 class="page-header-title"><i class="fa-solid fa-desktop"></i> {{ t('os_templates') }}</h1>
<p class="page-header-sub">{{ t('manage_os_templates_description') }}</p>
</div>
<div class="page-header-right" data-permission="templates.manage">
<button onclick="showAutoImportModal()" class="btn btn-secondary"><i class="fa-solid fa-rotate"></i> {{ t('auto_import') }}</button>
<button onclick="showAddGroupModal()" class="btn btn-secondary"><i class="fa-solid fa-folder"></i> {{ t('new_group') }}</button>
<button onclick="showAddTemplateModal()" class="btn btn-primary"><i class="fa-solid fa-plus"></i> {{ t('add_template') }}</button>
</div>
<p style="color: var(--text-secondary); margin-bottom: 1.5rem;">
{{ t('manage_os_templates_description') }}
</p>
</div>
<!-- Группы шаблонов -->

View File

@@ -2,31 +2,35 @@
{% set page_title = t('nav_proxmox') %}
{% block content %}
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3><i class="fa-solid fa-computer"></i> {{ t('proxmox_servers') }}</h3>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<div data-permission="proxmox.cluster.manage" style="position: relative;">
<button onclick="toggleClusterDropdown(event)" id="clusterMenuBtn" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">
<i class="fa-solid fa-network-wired"></i> Кластер <i class="fa-solid fa-caret-down" style="font-size: 0.75rem; margin-left: 2px;"></i>
<div class="page-header-block">
<div class="page-header-left">
<nav class="breadcrumb-nav">{{ t('dashboard') }} / {{ t('nav_proxmox') }}</nav>
<h1 class="page-header-title"><i class="fa-solid fa-computer"></i> {{ t('proxmox_servers') }}</h1>
</div>
<div class="page-header-right">
<div data-permission="proxmox.cluster.manage" style="position: relative;">
<button onclick="toggleClusterDropdown(event)" id="clusterMenuBtn" class="btn btn-secondary">
<i class="fa-solid fa-network-wired"></i> {{ t('cluster') if t('cluster') else 'Кластер' }} <i class="fa-solid fa-caret-down" style="font-size: 0.75rem; margin-left: 2px;"></i>
</button>
<div id="clusterDropdown" class="cluster-dropdown" style="display: none;">
<button onclick="showCreateClusterModal(); closeClusterDropdown();" class="cluster-dropdown-item">
<i class="fa-solid fa-circle-plus" style="color: var(--success);"></i> Создать кластер
</button>
<button onclick="showJoinClusterModal(); closeClusterDropdown();" class="cluster-dropdown-item">
<i class="fa-solid fa-link" style="color: var(--info);"></i> Присоединить к кластеру
</button>
<button onclick="showEjectNodeModal(); closeClusterDropdown();" class="cluster-dropdown-item">
<i class="fa-solid fa-right-from-bracket" style="color: var(--danger);"></i> Исключить ноду
</button>
<div id="clusterDropdown" class="cluster-dropdown" style="display: none;">
<button onclick="showCreateClusterModal(); closeClusterDropdown();" class="cluster-dropdown-item">
<i class="fa-solid fa-circle-plus" style="color: var(--success);"></i> Создать кластер
</button>
<button onclick="showJoinClusterModal(); closeClusterDropdown();" class="cluster-dropdown-item">
<i class="fa-solid fa-link" style="color: var(--info);"></i> Присоединить к кластеру
</button>
<button onclick="showEjectNodeModal(); closeClusterDropdown();" class="cluster-dropdown-item">
<i class="fa-solid fa-right-from-bracket" style="color: var(--danger);"></i> Исключить ноду
</button>
</div>
</div>
<div data-permission="proxmox.servers.add">
<button onclick="showAddServerModal()" class="btn btn-primary"><i class="fa-solid fa-plus"></i> {{ t('add_server') }}</button>
</div>
</div>
<div data-permission="proxmox.servers.add">
<button onclick="showAddServerModal()" class="btn btn-primary"><i class="fa-solid fa-plus"></i> {{ t('add_server') }}</button>
</div>
</div>
</div>
<div class="card">
{% if proxmox_servers %}
<div class="table-container">

View File

@@ -1,27 +1,19 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header" style="margin-bottom: 1.5rem;">
<div style="display:flex; align-items:center; gap:1rem; flex-wrap:wrap;">
<div>
<h1 style="font-size:1.5rem; font-weight:700; margin:0; color:var(--text-primary);">
<i class="fa-solid fa-list-check" style="color:#667eea; margin-right:.5rem;"></i>
Менеджер задач
</h1>
<p style="margin:.25rem 0 0; color:var(--text-muted); font-size:.87rem;">
Все активные и завершённые задачи в реальном времени
</p>
</div>
<div style="margin-left:auto; display:flex; gap:.75rem; align-items:center;">
<div id="wsStatus" style="display:flex; align-items:center; gap:.4rem; font-size:.78rem; color:var(--text-muted);">
<span class="task-dot" id="wsDot" style="background:#6b7280;"></span>
<span id="wsStatusText">Подключение...</span>
</div>
<button class="btn btn-sm" style="background:var(--bg-surface); border:1px solid var(--border-default); color:var(--text-secondary); border-radius:8px; padding:.35rem .85rem;"
onclick="TaskManager.refresh()" title="Обновить">
<i class="fa-solid fa-rotate-right"></i>
</button>
<div class="page-header-block">
<div class="page-header-left">
<h1 class="page-header-title"><i class="fa-solid fa-list-check"></i> Менеджер задач</h1>
<p class="page-header-sub">Все активные и завершённые задачи в реальном времени</p>
</div>
<div class="page-header-right">
<div id="wsStatus" style="display:flex; align-items:center; gap:.4rem; font-size:.78rem; color:var(--text-muted);">
<span class="task-dot" id="wsDot" style="background:#6b7280;"></span>
<span id="wsStatusText">Подключение...</span>
</div>
<button class="btn btn-secondary" onclick="TaskManager.refresh()" title="{{ t('refresh') }}">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
</div>

View File

@@ -3,13 +3,12 @@
{% block title %}{{ t('users_management') }}{% endblock %}
{% block content %}
<!-- Page Header -->
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 600; margin: 0; color: var(--text-primary);"><i class="fa-solid fa-users"></i> {{ t('users_management') }}</h1>
<p style="color: var(--text-secondary); margin-top: 0.25rem;">{{ t('dashboard') }} / {{ t('settings') }} / {{ t('users') }}</p>
<div class="page-header-block">
<div class="page-header-left">
<nav class="breadcrumb-nav">{{ t('dashboard') }} / {{ t('settings') }} / {{ t('users') }}</nav>
<h1 class="page-header-title"><i class="fa-solid fa-users"></i> {{ t('users_management') }}</h1>
</div>
<div data-permission="users.create">
<div class="page-header-right" data-permission="users.create">
<button class="btn btn-success" onclick="showAddUserModal()">
<i class="fa-solid fa-plus"></i> {{ t('add_user') }}
</button>

View File

@@ -697,6 +697,16 @@
}
</style>
<div class="page-header-block">
<div class="page-header-left">
<nav class="breadcrumb-nav">{{ t('dashboard') }} / {{ t('nav_vms') }}</nav>
<h1 class="page-header-title"><i class="fa-solid fa-cube"></i> {{ t('nav_vms') }}</h1>
</div>
<div class="page-header-right" id="vmPageActions">
<!-- actions populated after server selection -->
</div>
</div>
<div class="vm-table-container">
<!-- Tabs -->
<div class="vm-tabs">