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

@@ -2,9 +2,11 @@ from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from sqlalchemy import func, distinct
from datetime import datetime, timedelta
from ..db import get_db
from ..models import ProxmoxServer
from ..models import ProxmoxServer, AuditLog
from ..template_helpers import add_i18n_context
router = APIRouter()
@@ -17,13 +19,42 @@ def dashboard(request: Request, db: Session = Depends(get_db)):
total_servers = db.query(ProxmoxServer).count()
online_servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online == True).count()
offline_servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online == False).count()
# Count distinct clusters (non-null cluster_name counts as one cluster each)
named_clusters = db.query(func.count(distinct(ProxmoxServer.cluster_name))).filter(
ProxmoxServer.cluster_name.isnot(None),
ProxmoxServer.cluster_name != ''
).scalar() or 0
standalone_count = db.query(ProxmoxServer).filter(
(ProxmoxServer.cluster_name == None) | (ProxmoxServer.cluster_name == '')
).count()
total_clusters = named_clusters + standalone_count
# Alerts in last 24 hours
since_24h = datetime.utcnow() - timedelta(hours=24)
total_alerts = db.query(AuditLog).filter(
AuditLog.level.in_(['error', 'critical']),
AuditLog.created_at >= since_24h
).count()
critical_alerts = db.query(AuditLog).filter(
AuditLog.level == 'critical',
AuditLog.created_at >= since_24h
).count()
# Recent audit events (last 5)
recent_audit = db.query(AuditLog).order_by(
AuditLog.created_at.desc()
).limit(5).all()
recent_servers = db.query(ProxmoxServer).order_by(ProxmoxServer.id.desc()).limit(6).all()
stats = {
"total_servers": total_servers,
"online_servers": online_servers,
"offline_servers": offline_servers,
"total_clusters": total_clusters,
"total_alerts": total_alerts,
"critical_alerts": critical_alerts,
}
lang = request.cookies.get("language", "en")
@@ -33,6 +64,7 @@ def dashboard(request: Request, db: Session = Depends(get_db)):
"request": request,
"stats": stats,
"recent_servers": recent_servers,
"recent_audit": recent_audit,
"page_title": t('nav_dashboard', lang),
}
context = add_i18n_context(request, context)

View File

@@ -2698,3 +2698,640 @@ body {
background: rgba(102,126,234,.2);
color: #667eea;
}
/* ============================================
Sidebar - Cluster Selector Section
============================================ */
.sidebar-cluster-section {
padding: 0 var(--spacing-md) var(--spacing-md);
border-bottom: 1px solid rgba(100,100,150,0.15);
margin-bottom: var(--spacing-sm);
}
.sidebar-cluster-label {
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.1em;
color: rgba(160,160,181,0.5);
text-transform: uppercase;
margin-bottom: 0.35rem;
padding-left: 2px;
}
.cluster-selector-btn {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: rgba(102,126,234,0.1);
border: 1px solid rgba(102,126,234,0.2);
border-radius: var(--radius-md);
color: #c8ccf5;
font-size: 0.875rem;
font-weight: 600;
padding: 8px 12px;
cursor: pointer;
transition: all var(--transition-fast);
text-align: left;
gap: 8px;
}
.cluster-selector-btn:hover {
background: rgba(102,126,234,0.18);
border-color: rgba(102,126,234,0.4);
color: #fff;
}
.cluster-name-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar.collapsed .sidebar-cluster-section {
display: none;
}
/* ============================================
Page Subtitle & Header Status
============================================ */
.page-subtitle {
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: 2px;
font-weight: 400;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: var(--radius-full);
font-size: 0.8125rem;
font-weight: 600;
white-space: nowrap;
}
.status-chip.status-ok {
background: rgba(16,185,129,0.12);
color: #10b981;
border: 1px solid rgba(16,185,129,0.25);
}
.status-chip.status-warning {
background: rgba(249,115,22,0.12);
color: #f97316;
border: 1px solid rgba(249,115,22,0.25);
}
.status-chip.status-error {
background: rgba(239,68,68,0.12);
color: #ef4444;
border: 1px solid rgba(239,68,68,0.25);
}
.status-chip-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
animation: pulse-dot 2.5s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(0.85); }
}
.header-datetime {
font-size: 0.8125rem;
color: var(--text-secondary);
font-weight: 500;
white-space: nowrap;
}
.version-badge {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
padding: 4px 10px;
border: 1px solid var(--border-default);
border-radius: var(--radius-full);
background: var(--bg-surface-hover);
white-space: nowrap;
}
/* Header title block (title + subtitle stacked) */
.header-title-block {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.header-title-block h2 {
margin: 0;
line-height: 1.3;
}
/* ============================================
Dashboard Stat Cards v2
============================================ */
.stats-grid-5 {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
@media (max-width: 1200px) {
.stats-grid-5 { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 768px) {
.stats-grid-5 { grid-template-columns: repeat(2, 1fr); gap: var(--spacing-sm); }
}
@media (max-width: 480px) {
.stats-grid-5 { grid-template-columns: 1fr; }
}
.stat-card-v2 {
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 1.25rem 1.5rem;
transition: border-color var(--transition-fast), transform var(--transition-fast);
animation: fadeIn 0.5s ease;
}
.stat-card-v2:hover {
border-color: var(--primary);
transform: translateY(-2px);
}
.stat-card-lbl {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.stat-card-val {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1.1;
margin-bottom: 0.25rem;
font-variant-numeric: tabular-nums;
}
.stat-card-sub {
font-size: 0.8125rem;
color: var(--text-muted);
font-weight: 400;
}
.stat-card-v2.card-alerts .stat-card-val { color: var(--danger); }
.stat-card-v2.card-alerts .stat-card-sub { color: var(--warning); }
/* ============================================
Resource Usage Cards (CPU / RAM / Storage)
============================================ */
.resource-cards-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
@media (max-width: 900px) {
.resource-cards-row { grid-template-columns: 1fr; }
}
.resource-card {
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 1.25rem 1.5rem;
animation: fadeIn 0.6s ease;
}
.resource-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.1rem;
}
.resource-card-title {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.resource-pct-badge {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.resource-pct-badge.pct-ok { color: #10b981; }
.resource-pct-badge.pct-warn { color: #f97316; }
.resource-pct-badge.pct-high { color: #ef4444; }
.resource-mini-chart {
height: 90px;
width: 100%;
margin-bottom: 0.75rem;
}
.resource-progress-bar {
height: 6px;
background: var(--bg-surface-hover);
border-radius: var(--radius-full);
overflow: hidden;
margin-top: 0.5rem;
}
.resource-progress-fill {
height: 100%;
border-radius: var(--radius-full);
transition: width 0.8s cubic-bezier(0.4,0,0.2,1);
}
.resource-progress-fill.cpu-fill { background: linear-gradient(90deg, #60a5fa, #3b82f6); }
.resource-progress-fill.ram-fill { background: linear-gradient(90deg, #a78bfa, #8b5cf6); }
.resource-progress-fill.disk-fill { background: linear-gradient(90deg, #34d399, #10b981); }
/* ============================================
Dashboard Main Grid (VM table + Nodes panel)
============================================ */
.dash-main-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
@media (max-width: 1024px) {
.dash-main-grid { grid-template-columns: 1fr; }
}
.dash-section {
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
overflow: hidden;
animation: fadeIn 0.6s ease;
}
.dash-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-muted);
}
.dash-section-title {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.dash-section-meta {
font-size: 0.75rem;
color: var(--text-muted);
}
.dash-section-link {
font-size: 0.75rem;
color: var(--primary);
text-decoration: none;
font-weight: 500;
transition: opacity var(--transition-fast);
}
.dash-section-link:hover { opacity: 0.75; }
/* VM Table Compact */
.vm-table-compact {
width: 100%;
border-collapse: collapse;
}
.vm-table-compact th {
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-muted);
padding: 0.6rem 1.25rem;
border-bottom: 1px solid var(--border-muted);
text-align: left;
background: transparent;
}
.vm-table-compact td {
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-muted);
font-size: 0.875rem;
color: var(--text-primary);
}
.vm-table-compact tr:last-child td { border-bottom: none; }
.vm-table-compact tr:hover td {
background: var(--bg-surface-hover);
}
.vm-name-cell {
font-family: var(--font-mono);
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary);
}
.vm-node-cell {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-muted);
}
.vm-metric-cell {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
.vm-status-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 600;
}
.vm-status-badge.running {
background: rgba(16,185,129,0.15);
color: #10b981;
}
.vm-status-badge.stopped {
background: rgba(100,100,150,0.15);
color: var(--text-muted);
}
.vm-status-badge.warning, .vm-status-badge.paused {
background: rgba(249,115,22,0.15);
color: #f97316;
}
.vm-status-badge .status-dot-sm {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* Nodes Panel */
.nodes-panel {
padding: 0;
}
.node-item {
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-muted);
transition: background var(--transition-fast);
}
.node-item:last-child { border-bottom: none; }
.node-item:hover { background: var(--bg-surface-hover); }
.node-item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.node-item-name {
display: flex;
align-items: center;
gap: 7px;
font-family: var(--font-mono);
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary);
}
.node-dot-online {
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 6px rgba(16,185,129,0.6);
flex-shrink: 0;
}
.node-dot-offline {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.node-vm-count {
font-size: 0.6875rem;
color: var(--text-muted);
font-weight: 400;
font-family: var(--font-sans);
}
.node-bar-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 3px;
}
.node-bar-lbl {
font-size: 0.6875rem;
color: var(--text-muted);
width: 30px;
flex-shrink: 0;
}
.node-bar-wrap {
flex: 1;
height: 5px;
background: var(--bg-surface-hover);
border-radius: var(--radius-full);
overflow: hidden;
}
.node-bar-fill {
height: 100%;
border-radius: var(--radius-full);
transition: width 0.8s cubic-bezier(0.4,0,0.2,1);
}
.node-bar-fill.cpu { background: linear-gradient(90deg, #60a5fa, #3b82f6); }
.node-bar-fill.ram { background: linear-gradient(90deg, #a78bfa, #8b5cf6); }
.node-bar-pct {
font-size: 0.6875rem;
color: var(--text-muted);
width: 30px;
text-align: right;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
/* ============================================
Recent Audit Events
============================================ */
.audit-events-list {
padding: 0;
}
.audit-row {
display: grid;
grid-template-columns: 70px 80px auto 100px;
align-items: center;
gap: 1rem;
padding: 0.7rem 1.25rem;
border-bottom: 1px solid var(--border-muted);
font-size: 0.8125rem;
transition: background var(--transition-fast);
}
.audit-row:last-child { border-bottom: none; }
.audit-row:hover { background: var(--bg-surface-hover); }
.audit-time {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
}
.audit-user {
color: var(--text-secondary);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.audit-action-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.audit-action-tag.info { background: rgba(59,130,246,0.15); color: #60a5fa; }
.audit-action-tag.success { background: rgba(16,185,129,0.15); color: #10b981; }
.audit-action-tag.warning { background: rgba(249,115,22,0.15); color: #f97316; }
.audit-action-tag.error, .audit-action-tag.critical { background: rgba(239,68,68,0.15); color: #ef4444; }
.audit-action-tag.default { background: var(--bg-surface-hover); color: var(--text-muted); }
.audit-resource {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 768px) {
.audit-row { grid-template-columns: 60px auto auto; }
.audit-resource { display: none; }
}
/* ============================================
Page Header Block (unified across all pages)
============================================ */
.page-header-block {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-xl);
flex-wrap: wrap;
gap: var(--spacing-md);
}
.page-header-left {}
.page-header-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 0.2rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.page-header-sub {
font-size: 0.8125rem;
color: var(--text-muted);
margin: 0;
}
.page-header-right {
display: flex;
align-items: center;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
/* Breadcrumb */
.breadcrumb-nav {
font-size: 0.75rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.2rem;
}
.breadcrumb-nav a {
color: var(--text-muted);
text-decoration: none;
transition: color var(--transition-fast);
}
.breadcrumb-nav a:hover { color: var(--primary); }
.breadcrumb-sep { opacity: 0.4; }

View File

@@ -49,4 +49,7 @@ def add_i18n_context(request: Request, context: dict) -> dict:
context['panel_name'] = settings.PANEL_NAME
context['custom_logo_exists'] = custom_logo_exists()
# Add version
context['version'] = settings.VERSION
return context

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">