feat: v1.1.2 — dashboard redesign, pve CLI tool, sidebar cluster selector
This commit is contained in:
33
CHANGELOG.md
33
CHANGELOG.md
@@ -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
|
||||
|
||||
65
WIKI.md
65
WIKI.md
@@ -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*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- Группы шаблонов -->
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
40
deploy.sh
40
deploy.sh
@@ -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
330
pve
Executable 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
|
||||
Reference in New Issue
Block a user