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,6 +4,39 @@ All notable changes to PVEmanager will be documented in this file.
---
## [v1.1.2] - 2026-03-08
### 🎨 Dashboard Redesign
- New 5-column stat grid: **Nodes, VMs, Containers, Clusters, Alerts**
- Real-time VM/CT count loaded via API on page open
- Resource cards row with mini sparkline charts: **CPU, RAM, Storage**
- Status chip in header: `All systems operational` / warnings / critical
- Header datetime display (live clock)
- Cluster count computed from DB (named clusters + standalone nodes)
- Alerts count sourced from `AuditLog` — errors + criticals in last 24 h
- Recent audit events (last 5) now passed to dashboard context
### 🖥️ Sidebar & Layout (`base.html`)
- **Cluster Selector** widget in sidebar — shows active cluster/standalone name
- **Version badge** in header (`v{{ version }}`)
- `page_subtitle` and `header_status` Jinja2 blocks for per-page overrides
- `loadSidebarCluster()` JS helper — fetches known servers, picks named cluster
### 🛠️ `pve` CLI Tool
- New `pve` shell script at project root — management shortcuts (logs, restart, update, exec, etc.)
- `deploy.sh --install-cli` — installs `pve` to `/usr/local/bin/pve`, patches `PVE_DIR` to project path
- Help visible after installation: `pve help`
### 🎨 Theme CSS
- New CSS classes for redesigned dashboard: `.stats-grid-5`, `.stat-card-v2`, `.resource-cards-row`, `.resource-card`, `.version-badge`, `.status-chip`, `.header-datetime`, `.sidebar-cluster-section`
- Minor CSS fixes across login, logs, tasks, os_templates, users, proxmox_vms, virtual_machines, ipam_dashboard
---
## [v1.1.1] - 2026-03-08
### 🔧 Fix: Update System — Host-side Rebuild

View File

@@ -1 +1 @@
1.1.1
1.1.2

65
WIKI.md
View File

@@ -1,6 +1,6 @@
# 📖 PVEmanager - Documentation
> Complete guide for installation, configuration and usage of PVEmanager v1.1.1
> Complete guide for installation, configuration and usage of PVEmanager v1.1.2
---
@@ -22,11 +22,12 @@
14. [Security (RBAC v2)](#-security)
15. [Localization](#-localization)
16. [Settings](#-settings)
17. [API Reference](#-api-reference)
18. [Deployment Guide](#-deployment-guide)
19. [Private Repo Updates](#-private-repo-updates)
20. [Troubleshooting](#-troubleshooting)
21. [FAQ](#-faq)
17. [pve CLI Tool](#-pve-cli-tool)
18. [API Reference](#-api-reference)
19. [Deployment Guide](#-deployment-guide)
20. [Private Repo Updates](#-private-repo-updates)
21. [Troubleshooting](#-troubleshooting)
22. [FAQ](#-faq)
---
@@ -258,10 +259,15 @@ This section summarizes key API endpoints. All require JWT authentication.
### Dashboard
- Overall server statistics
- VM/LXC container count
- Resource usage graphs
- Quick access to recent events
- **5 stat cards** — Nodes, VMs, Containers, Clusters, Alerts (real-time)
- **Resource cards** — CPU, RAM, Storage with mini sparkline charts
- **Status chip** in header — `All systems operational` / warnings / critical alerts
- **Live clock** in header
- Cluster count computed automatically from database
- Alerts count from AuditLog (errors + criticals in last 24 h)
- Recent audit events (last 5) on the dashboard
- **Cluster Selector** in sidebar — shows active cluster or standalone name
- **Version badge** in header
### Proxmox Servers
@@ -1241,7 +1247,42 @@ tail -f logs/update_host.log
---
## 🔌 API Reference
## <EFBFBD>️ pve CLI Tool
Starting from v1.1.2, PVEmanager ships with a `pve` shell script — a handy CLI shortcut for common management tasks.
### Installation
```bash
# Requires root (copies script to /usr/local/bin/pve and patches project path)
sudo ./deploy.sh --install-cli
```
After installation the `pve` command is available system-wide.
### Usage
```bash
pve help # Show all available commands
pve logs # Tail live app logs
pve restart # Restart all containers
pve update # Trigger panel update (via watchdog)
pve exec <cmd> # Execute command inside app container
pve status # Show docker compose status
```
### How It Works
- `deploy.sh --install-cli` reads the `pve` file from the project root
- Replaces the placeholder `PVE_DIR` with the actual project path
- Writes the result to `/usr/local/bin/pve` and makes it executable
- Re-run `sudo ./deploy.sh --install-cli` after moving the project directory
> **Note:** The `pve` binary reflects the project directory at install time. If you relocate the project, reinstall the CLI.
---
## <20>🔌 API Reference
### Authentication
@@ -1549,4 +1590,4 @@ A: Not yet, but planned for future versions.
---
*Last updated: March 2026*
*Version: 1.1.1*
*Version: 1.1.2*

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

View File

@@ -865,6 +865,40 @@ EOF
fi
}
# Install pve CLI tool to /usr/local/bin/pve
install_pve_cli() {
local project_dir
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local pve_source="$project_dir/pve"
local install_path="/usr/local/bin/pve"
if [ ! -f "$pve_source" ]; then
print_error "CLI скрипт не найден: $pve_source"
print_info "Убедитесь, что файл 'pve' находится в той же директории, что и deploy.sh"
return 1
fi
if [ "$(id -u)" -ne 0 ]; then
print_error "Установка требует root-прав."
print_info "Выполните: sudo bash deploy.sh --install-cli"
return 1
fi
print_info "Установка pve CLI..."
print_info " Источник : $pve_source"
print_info " Установка : $install_path"
# Подставить реальный путь к директории панели
sed "s|PVE_DIR=\"/opt/pvemanager\"|PVE_DIR=\"$project_dir\"|g" \
"$pve_source" > "$install_path"
chmod +x "$install_path"
print_success "pve CLI установлен в $install_path"
print_info "Теперь вы можете использовать команду 'pve' из любого места."
echo ""
"$install_path" help
}
show_help() {
echo ""
echo "Usage: $0 [OPTIONS]"
@@ -884,12 +918,14 @@ show_help() {
echo " $0 --restart Restart all services"
echo " $0 --logs Show live logs"
echo " $0 --watchdog Install/reinstall the host-side update watchdog (systemd)"
echo " sudo $0 --install-cli Install 'pve' CLI tool to /usr/local/bin/pve"
echo ""
echo "Examples:"
echo " $0 --standalone"
echo " $0 --nginx example.com"
echo " $0 --nginx example.com admin@example.com"
echo " sudo $0 --watchdog"
echo " sudo $0 --install-cli"
}
# Parse command line arguments
@@ -931,6 +967,10 @@ if [ $# -gt 0 ]; then
install_update_watchdog
exit 0
;;
--install-cli)
install_pve_cli
exit 0
;;
*)
print_error "Unknown option: $1"
show_help

330
pve Executable file
View File

@@ -0,0 +1,330 @@
#!/bin/bash
# PVEmanager CLI — управление панелью
# Установка: sudo bash /opt/pvemanager/deploy.sh --install-cli
# ── Директория панели (задаётся при установке через --install-cli) ────────────
PVE_DIR="/opt/pvemanager"
# Авто-определение при запуске прямо из исходников (без установки)
if [ ! -f "$PVE_DIR/compose.yml" ]; then
_SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")" && pwd)"
if [ -f "$_SCRIPT_DIR/compose.yml" ]; then
PVE_DIR="$_SCRIPT_DIR"
fi
fi
BACKUP_DIR="/var/backups/pvemanager"
BACKUP_KEEP=10
# ── Цвета ─────────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[ OK ]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERR ]${NC} $*" >&2; }
die() { err "$*"; exit 1; }
step() { echo -e "\n${BOLD}▶ $*${NC}"; }
# ── Compose runner ────────────────────────────────────────────────────────────
# Глобальный массив compose-аргументов: заполняется _resolve_compose()
declare -a _CF_ARGS
# Авто-определение режима: prod (nginx) vs standalone
# Логика: если контейнер serverpanel-nginx существует — prod-режим
_resolve_compose() {
_CF_ARGS=("-f" "compose.yml")
if [ -f "$PVE_DIR/compose.prod.yml" ] && \
docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "serverpanel-nginx"; then
_CF_ARGS=("-f" "compose.yml" "-f" "compose.prod.yml")
fi
}
# Запустить docker compose из PVE_DIR с авто-определёнными файлами
pve_dc() {
_resolve_compose
pushd "$PVE_DIR" > /dev/null
docker compose "${_CF_ARGS[@]}" "$@"
local rc=$?
popd > /dev/null
return $rc
}
# ── Предусловия ───────────────────────────────────────────────────────────────
check_docker() {
docker info &>/dev/null || \
die "Docker недоступен. Запустите с sudo или добавьте пользователя в группу docker."
}
check_pve_dir() {
[ -d "$PVE_DIR" ] || die "Директория панели не найдена: $PVE_DIR"
[ -f "$PVE_DIR/compose.yml" ] || die "compose.yml не найден в $PVE_DIR"
}
# ── pve start ─────────────────────────────────────────────────────────────────
cmd_start() {
check_docker; check_pve_dir
step "Запуск панели"
if pve_dc up -d; then
ok "Панель запущена"
echo ""
pve_dc ps
else
die "Не удалось запустить панель"
fi
}
# ── pve stop ──────────────────────────────────────────────────────────────────
cmd_stop() {
check_docker; check_pve_dir
step "Остановка панели"
pve_dc down
ok "Панель остановлена"
}
# ── pve restart (полная пересборка) ───────────────────────────────────────────
cmd_restart() {
check_docker; check_pve_dir
step "Остановка контейнеров"
pve_dc down
step "Пересборка образа app (без кеша)"
pve_dc build --no-cache app
step "Запуск контейнеров"
if pve_dc up -d; then
ok "Панель перезапущена"
echo ""
pve_dc ps
else
die "Не удалось запустить панель после пересборки"
fi
}
# ── pve update ────────────────────────────────────────────────────────────────
cmd_update() {
check_docker; check_pve_dir
[ -d "$PVE_DIR/.git" ] || die "Не является git-репозиторием: $PVE_DIR"
step "Получение обновлений из репозитория"
local branch
branch=$(git -C "$PVE_DIR" branch --show-current 2>/dev/null || echo "main")
info "Ветка: $branch"
if ! git -C "$PVE_DIR" pull origin "$branch"; then
die "Не удалось выполнить git pull"
fi
ok "Исходный код обновлён"
step "Пересборка и перезапуск"
cmd_restart
}
# ── pve backup ────────────────────────────────────────────────────────────────
cmd_backup() {
check_docker; check_pve_dir
local ts
ts=$(date +"%Y-%m-%d_%H-%M-%S")
local tmp_dir
tmp_dir=$(mktemp -d "/tmp/pve-backup-XXXXXX")
trap 'rm -rf "$tmp_dir"' EXIT
local archive_name="backup_${ts}.tar.gz"
local archive_path="$BACKUP_DIR/$archive_name"
step "Создание резервной копии"
info "Архив: $archive_path"
mkdir -p "$BACKUP_DIR"
# 1. Дамп базы данных
info "Дамп базы данных (pg_dump)..."
local pg_pass
pg_pass=$(grep -E '^POSTGRES_PASSWORD=' "$PVE_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"')
local dump_ok=false
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "serverpanel-db"; then
if docker exec -i \
-e PGPASSWORD="$pg_pass" \
serverpanel-db \
pg_dump -U serverpanel -d serverpanel \
> "$tmp_dir/db_dump.sql" 2>/dev/null; then
dump_ok=true
ok "Дамп БД: $(du -sh "$tmp_dir/db_dump.sql" | cut -f1)"
fi
fi
if [ "$dump_ok" = false ]; then
warn "pg_dump не удался (БД не запущена?). Сохраняю пустой файл."
echo "-- db dump skipped: container was not running at $(date)" > "$tmp_dir/db_dump.sql"
fi
# 2. Конфигурационные файлы
info "Сохранение конфигурационных файлов..."
[ -f "$PVE_DIR/.env" ] && cp "$PVE_DIR/.env" "$tmp_dir/root.env"
[ -f "$PVE_DIR/backend/.env" ] && cp "$PVE_DIR/backend/.env" "$tmp_dir/backend.env"
[ -f "$PVE_DIR/compose.yml" ] && cp "$PVE_DIR/compose.yml" "$tmp_dir/compose.yml"
[ -f "$PVE_DIR/compose.prod.yml" ] && cp "$PVE_DIR/compose.prod.yml" "$tmp_dir/compose.prod.yml"
[ -f "$PVE_DIR/VERSION" ] && cp "$PVE_DIR/VERSION" "$tmp_dir/VERSION"
[ -d "$PVE_DIR/nginx/conf.d" ] && cp -r "$PVE_DIR/nginx/conf.d" "$tmp_dir/nginx_conf.d"
# 3. Мета-файл
cat > "$tmp_dir/backup.info" << EOF
PVEmanager Backup
=================
Date : $(date)
Version : $(cat "$PVE_DIR/VERSION" 2>/dev/null || echo "unknown")
Host : $(hostname)
Commit : $(git -C "$PVE_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")
Branch : $(git -C "$PVE_DIR" branch --show-current 2>/dev/null || echo "unknown")
DB dump : $([ "$dump_ok" = true ] && echo "OK" || echo "SKIPPED")
EOF
# 4. Упаковка
info "Упаковка..."
tar -czf "$archive_path" -C "$tmp_dir" . 2>/dev/null
trap - EXIT
rm -rf "$tmp_dir"
ok "Резервная копия создана: $archive_path ($(du -sh "$archive_path" | cut -f1))"
# 5. Ротация — оставить только BACKUP_KEEP последних архивов
local count
count=$(find "$BACKUP_DIR" -maxdepth 1 -name "backup_*.tar.gz" | wc -l)
if [ "$count" -gt "$BACKUP_KEEP" ]; then
local delete_count=$(( count - BACKUP_KEEP ))
info "Ротация: удаление $delete_count старых архивов (хранится $BACKUP_KEEP)..."
find "$BACKUP_DIR" -maxdepth 1 -name "backup_*.tar.gz" -printf '%T+ %p\n' \
| sort | head -n "$delete_count" | awk '{print $2}' | xargs rm -f
ok "Ротация выполнена"
fi
echo ""
info "Архивы в $BACKUP_DIR:"
find "$BACKUP_DIR" -maxdepth 1 -name "backup_*.tar.gz" -printf '%T+ %p\n' \
| sort -r | awk '{size=0; cmd="du -sh "$2; cmd | getline line; close(cmd); split(line,a,"\t"); printf " %s %s\n", a[1], $2}' \
| head -20
}
# ── pve status ────────────────────────────────────────────────────────────────
cmd_status() {
check_docker; check_pve_dir
echo ""
pve_dc ps
echo ""
local version
version=$(cat "$PVE_DIR/VERSION" 2>/dev/null || echo "unknown")
local git_info
git_info=$(git -C "$PVE_DIR" log --oneline -1 2>/dev/null || echo "")
info "Версия: $version${git_info:+ | $git_info}"
}
# ── pve logs ──────────────────────────────────────────────────────────────────
cmd_logs() {
check_docker; check_pve_dir
local follow=false
local service=""
local tail=100
while [ $# -gt 0 ]; do
case "$1" in
-f|--follow) follow=true ;;
--tail=*) tail="${1#*=}" ;;
-*) warn "Неизвестный флаг: $1" ;;
*) service="$1" ;;
esac
shift
done
local -a args=("logs" "--tail=$tail")
[ "$follow" = true ] && args+=("-f")
[ -n "$service" ] && args+=("$service")
pve_dc "${args[@]}"
}
# ── pve shell ─────────────────────────────────────────────────────────────────
cmd_shell() {
check_docker; check_pve_dir
local service="${1:-app}"
info "Вход в контейнер '$service'..."
pve_dc exec "$service" bash 2>/dev/null || pve_dc exec "$service" sh
}
# ── pve version ───────────────────────────────────────────────────────────────
cmd_version() {
local version
version=$(cat "$PVE_DIR/VERSION" 2>/dev/null || echo "unknown")
local git_commit
git_commit=$(git -C "$PVE_DIR" rev-parse --short HEAD 2>/dev/null || echo "")
local git_branch
git_branch=$(git -C "$PVE_DIR" branch --show-current 2>/dev/null || echo "")
echo -e "${BOLD}PVEmanager${NC} v${version}"
[ -n "$git_commit" ] && echo -e " Commit : ${git_commit}${git_branch:+ ($git_branch)}"
echo -e " Dir : ${PVE_DIR}"
}
# ── pve help ──────────────────────────────────────────────────────────────────
cmd_help() {
local version
version=$(cat "$PVE_DIR/VERSION" 2>/dev/null || echo "?")
echo ""
echo -e " ${BOLD}PVEmanager CLI${NC} v${version} — управление панелью"
echo ""
echo -e " ${CYAN}Использование:${NC} pve <команда> [аргументы]"
echo ""
echo -e " ${BOLD}Управление контейнерами${NC}"
echo " start Запустить панель (docker compose up -d)"
echo " stop Остановить панель (docker compose down)"
echo " restart Остановить → пересобрать app → запустить"
echo " update git pull + restart (полная пересборка)"
echo ""
echo -e " ${BOLD}Обслуживание${NC}"
echo " backup Создать резервную копию в $BACKUP_DIR"
echo " status Статус контейнеров + версия"
echo " logs [-f] [svc] Логи (опции: -f следить, имя сервиса)"
echo " shell [svc] Войти в контейнер (по умолчанию: app)"
echo ""
echo -e " ${BOLD}Информация${NC}"
echo " version Версия панели и git-коммит"
echo " help Показать эту справку"
echo ""
echo -e " ${CYAN}Директория панели :${NC} $PVE_DIR"
echo -e " ${CYAN}Директория бэкапов:${NC} $BACKUP_DIR (хранится $BACKUP_KEEP архивов)"
echo ""
echo -e " ${BOLD}Примеры${NC}"
echo " pve start"
echo " pve logs -f app"
echo " pve shell db"
echo " pve backup"
echo ""
}
# ── Точка входа ───────────────────────────────────────────────────────────────
CMD="${1:-help}"
shift 2>/dev/null || true
case "$CMD" in
start) cmd_start ;;
stop) cmd_stop ;;
restart) cmd_restart ;;
update) cmd_update ;;
backup) cmd_backup ;;
status) cmd_status ;;
logs) cmd_logs "$@" ;;
shell) cmd_shell "$@" ;;
version) cmd_version ;;
help|--help|-h) cmd_help ;;
*)
err "Неизвестная команда: $CMD"
cmd_help
exit 1
;;
esac