feat: v1.1.3 — dashboard alerts panel, sparkline charts, log sanitisation, CSS vars

This commit is contained in:
markmorado
2026-03-10 14:10:00 +01:00
parent a0a502f82a
commit 3947dae7b9
16 changed files with 638 additions and 267 deletions

View File

@@ -4,6 +4,36 @@ All notable changes to PVEmanager will be documented in this file.
---
## [v1.1.3] - 2026-03-10
### 🚨 Dashboard — Alerts Panel & Real-time Node Stats
- New API endpoint `GET /api/dashboard/alerts` — returns error/critical `AuditLog` entries for the last 24 h (up to 50), protected by `logs.view` permission
- Alerts modal (`openAlertsModal()`) on the dashboard status chip — click to view recent errors
- Mini sparkline charts (Chart.js) for **CPU, RAM, Storage** per node — updates in-place without re-rendering
- Node stats fetched in parallel via `Promise.all` for all online servers
- Full i18n coverage for new dashboard elements: `nodes_label`, `clusters_label`, `alerts_label`, `all_online`, `all_healthy`, `no_alerts`, `all_systems_operational`, `view_all`, `recent_audit_events`, `no_audit_events`, `last_24h`, `ram`, `ram_usage`, `storage`, `paused`, and more
### 🔒 Security — Log sanitisation
- Removed IP addresses from structured log messages in `servers.py` to prevent credential leakage via logs
- `logging_middleware.py` now suppresses noisy GET polling traffic — requests to `/proxmox/api/`, `/api/notifications/unread-count`, `/settings/api/panel` etc. are only logged on HTTP errors (≥ 400) or slow responses (> 5 s)
### 🎨 UI — CSS variables & GitHub stars
- `base.html`: GitHub stars counter switched from Gitea API to GitHub API (`api.github.com`, `stargazers_count`)
- Hard-coded `rgba()` colours in `backups.html` and `proxmox_vms.html` replaced with CSS theme variables (`var(--info-light)`, `var(--warning-light)`)
- Backups server selector redesigned — added icon and label for better clarity
### 📖 WIKI — Fixes & new content
- Fixed broken anchor links in Table of Contents (`#deployment-guide` → `#installation-and-deployment`)
- Added **Snapshots** section with creation guide, management table, and full API reference
- Added **Main Features / Dashboard** section
- Fixed broken emoji characters in section headings (`🛠️ pve CLI Tool`, `🔌 API Reference`)
---
## [v1.1.2] - 2026-03-08
### 🎨 Dashboard Redesign

View File

@@ -4,7 +4,7 @@
**Modern web panel for managing Proxmox servers, virtual machines and LXC containers**
[![Version](https://img.shields.io/badge/version-1.0.3-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.1.3-blue?style=flat-square)](CHANGELOG.md)
[![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE)
[![Docker](https://img.shields.io/badge/docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white)](compose.yml)
[![Python](https://img.shields.io/badge/python-3.12-3776AB?style=flat-square&logo=python&logoColor=white)](backend/)

View File

@@ -1 +1 @@
1.1.2
1.1.3

64
WIKI.md
View File

@@ -1,6 +1,6 @@
# 📖 PVEmanager - Documentation
> Complete guide for installation, configuration and usage of PVEmanager v1.1.2
> Complete guide for installation, configuration and usage of PVEmanager v1.1.3
---
@@ -24,8 +24,8 @@
16. [Settings](#-settings)
17. [pve CLI Tool](#-pve-cli-tool)
18. [API Reference](#-api-reference)
19. [Deployment Guide](#-deployment-guide)
20. [Private Repo Updates](#-private-repo-updates)
19. [Deployment Guide](#-installation-and-deployment)
20. [Private Repo Updates](#-settings)
21. [Troubleshooting](#-troubleshooting)
22. [FAQ](#-faq)
@@ -255,7 +255,9 @@ This section summarizes key API endpoints. All require JWT authentication.
- `POST /api/sync-vms` — force immediate VM sync from all Proxmox servers
---
## 🏠 Main Features
### Dashboard
@@ -646,6 +648,54 @@ See [OS Templates - Cross-Node Template Deployment](#cross-node-template-deploym
---
## 📸 Snapshots
### Overview
Snapshots allow you to save and restore the state of VMs and containers at any point in time.
### Creating a Snapshot
1. Open VM or container details
2. Go to the **Snapshots** tab
3. Click **Create Snapshot**
4. Enter a name and description
5. Optionally include RAM state (QEMU VMs only)
### Managing Snapshots
| Action | Description |
|--------|-------------|
| Create | Save current VM state |
| Rollback | Restore to a previous snapshot state |
| Delete | Remove an existing snapshot |
> ⚠️ Rolling back will discard all changes made after the snapshot was created!
### Snapshot Queue
Snapshot operations are processed through the task queue system. Progress is tracked and results are saved for each item.
### API Endpoints
```bash
# List snapshots
GET /api/{server_id}/vm/{vmid}/snapshots?node={node}
# Create snapshot
POST /api/{server_id}/vm/{vmid}/snapshots?node={node}
# Delete snapshot
DELETE /api/{server_id}/vm/{vmid}/snapshots/{snapname}?node={node}
# Rollback to snapshot
POST /api/{server_id}/vm/{vmid}/snapshots/{snapname}/rollback?node={node}
```
> For LXC containers use `/container/` instead of `/vm/` in the endpoints above.
---
## 💾 Backups
### Overview
@@ -1247,7 +1297,7 @@ tail -f logs/update_host.log
---
## <EFBFBD>️ pve CLI Tool
## 🛠️ pve CLI Tool
Starting from v1.1.2, PVEmanager ships with a `pve` shell script — a handy CLI shortcut for common management tasks.
@@ -1282,7 +1332,7 @@ pve status # Show docker compose status
---
## <EFBFBD>🔌 API Reference
## 🔌 API Reference
### Authentication
@@ -1589,5 +1639,5 @@ A: Not yet, but planned for future versions.
---
*Last updated: March 2026*
*Version: 1.1.2*
*Last updated: March 10, 2026*
*Version: 1.1.3*

View File

@@ -1,13 +1,14 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, JSONResponse, 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, AuditLog
from ..models import ProxmoxServer, AuditLog, User
from ..template_helpers import add_i18n_context
from ..rbac.decorators import PermissionChecker
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@@ -72,6 +73,37 @@ def dashboard(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("dashboard.html", context)
@router.get("/api/dashboard/alerts")
def get_dashboard_alerts(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("logs.view"))
):
"""Вернуть события error/critical за последние 24 ч для дашборда"""
since_24h = datetime.utcnow() - timedelta(hours=24)
alerts = db.query(AuditLog).filter(
AuditLog.level.in_(['error', 'critical']),
AuditLog.created_at >= since_24h
).order_by(AuditLog.created_at.desc()).limit(50).all()
return JSONResponse(content={
"alerts": [
{
"id": a.id,
"level": a.level,
"category": a.category,
"action": a.action,
"message": a.message,
"username": a.username,
"resource_name": a.resource_name,
"server_name": a.server_name,
"created_at": a.created_at.isoformat() if a.created_at else None,
}
for a in alerts
],
"total": len(alerts)
})
@router.get("/containers", response_class=HTMLResponse, include_in_schema=False)
def containers(request: Request):
lang = request.cookies.get("language", "en")

View File

@@ -113,7 +113,7 @@ def create_proxmox_server(
db.commit()
db.refresh(server)
logger.info(f"User {current_user.username} added Proxmox server: {server.name} ({server.ip_address})")
logger.info(f"User {current_user.username} added Proxmox server: {server.name}")
return server
@@ -187,7 +187,7 @@ async def auto_setup_proxmox_server(
detail="Не удалось получить auth ticket от Proxmox"
)
logger.info(f"Successfully authenticated to Proxmox {ip_address}")
logger.info("Successfully authenticated to Proxmox")
except http_requests.exceptions.RequestException as e:
logger.error(f"Connection error to Proxmox: {e}")
@@ -227,7 +227,7 @@ async def auto_setup_proxmox_server(
else:
token_data = token_response.json().get("data", {})
token_value = token_data.get("value")
logger.info(f"Created API token: {token_name}")
logger.info("Created API token successfully")
except Exception as e:
logger.warning(f"Error creating token: {e}, will use password auth")
@@ -346,7 +346,7 @@ async def auto_setup_proxmox_server(
db.commit()
db.refresh(server)
logger.info(f"User {current_user.username} auto-setup Proxmox server: {server.name} ({server.ip_address})")
logger.info(f"User {current_user.username} auto-setup Proxmox server: {server.name}")
return {
"cluster": False,
@@ -624,7 +624,7 @@ def get_all_resources(
for server in proxmox_servers:
try:
logger.info(f"Getting resources from server {server.name} ({server.ip_address}), use_password={server.use_password}")
logger.info(f"Getting resources from server {server.name}, use_password={server.use_password}")
# Создаём клиента для каждого сервера независимо
if server.use_password:
resources = get_proxmox_resources(
@@ -634,7 +634,7 @@ def get_all_resources(
verify_ssl=server.verify_ssl
)
else:
logger.info(f"Using API token for {server.name}: user={server.api_user}, token_name={server.api_token_name}")
logger.info(f"Using API token for {server.name}: user={server.api_user}")
resources = get_proxmox_resources(
host=server.ip_address,
user=server.api_user,

View File

@@ -1206,7 +1206,7 @@ def get_server_nodes(
except Exception:
pass
logger.info(f"Nodes for server {server_id} ({server.name}), cluster_id={cluster_id}: {nodes}")
logger.info(f"Nodes for server {server_id} ({server.name}), cluster_id={cluster_id}: {len(nodes)} node(s)")
return JSONResponse(content={"nodes": nodes, "cluster_id": cluster_id})
except Exception as e:
logger.error(f"Error getting nodes from {server.name}: {e}")
@@ -1611,7 +1611,7 @@ async def vnc_websocket_proxy(
encoded_ticket = urllib.parse.quote(vncticket, safe='')
proxmox_ws_url = f"wss://{server.ip_address}:8006/api2/json/nodes/{node}/{proxmox_vmtype}/{vmid}/vncwebsocket?port={port}&vncticket={encoded_ticket}"
logger.info(f"Connecting to Proxmox VNC: {server.ip_address}:8006 for {proxmox_vmtype}/{vmid}")
logger.info(f"Connecting to Proxmox VNC: {server.name} for {proxmox_vmtype}/{vmid}")
# SSL контекст для самоподписанных сертификатов
ssl_context = ssl.create_default_context()

View File

@@ -5149,6 +5149,72 @@ class I18nService:
"ru": "Удалить все",
"en": "Delete all"
},
# ---- Dashboard labels ----
"nodes_label": {
"ru": "Ноды",
"en": "Nodes"
},
"clusters_label": {
"ru": "Кластеры",
"en": "Clusters"
},
"alerts_label": {
"ru": "Оповещения",
"en": "Alerts"
},
"all_online": {
"ru": "Все онлайн",
"en": "All online"
},
"all_healthy": {
"ru": "Все работают",
"en": "All healthy"
},
"no_alerts": {
"ru": "Нет оповещений",
"en": "No alerts"
},
"all_systems_operational": {
"ru": "Все системы работают",
"en": "All systems operational"
},
"view_all": {
"ru": "Смотреть все →",
"en": "View all →"
},
"recent_audit_events": {
"ru": "Недавние события аудита",
"en": "Recent Audit Events"
},
"no_audit_events": {
"ru": "Нет событий аудита",
"en": "No audit events found"
},
"last_24h": {
"ru": "последние 24ч",
"en": "last 24h"
},
"ram": {
"ru": "RAM",
"en": "RAM"
},
"ram_usage": {
"ru": "Использование RAM",
"en": "RAM Usage"
},
"storage": {
"ru": "Хранилище",
"en": "Storage"
},
"paused": {
"ru": "Пауза",
"en": "Paused"
},
"stopped": {
"ru": "Остановлен",
"en": "Stopped"
},
}
@classmethod

View File

@@ -28,9 +28,21 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
# Paths that should only log errors (to reduce noise)
QUIET_PATHS = [
"/api/logs", # Don't log requests to view logs
"/api/logs", # Don't log requests to view logs
"/api/virtual-machines", # High frequency polling
]
# GET-only polling paths — only log on error/slow (dashboard real-time updates)
POLL_PATHS = [
"/proxmox/api/resources/all",
"/proxmox/api/servers",
"/settings/api/panel",
"/api/notifications/unread-count",
]
# Path prefixes for node polling (dynamic IDs, e.g. /proxmox/api/4/nodes)
POLL_PREFIXES = [
"/proxmox/api/", # all proxmox data reads
]
# Paths to log request body (important operations)
LOG_BODY_PATHS = [
@@ -106,7 +118,19 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
# Determine if we should log this request
should_log = self.enable_api_logging
# Suppress GET polling traffic — only log errors or very slow requests
if request.method == "GET":
for poll_path in self.POLL_PATHS:
if path == poll_path or path.startswith(poll_path):
should_log = response.status_code >= 400 or duration_ms > 5000
break
else:
for prefix in self.POLL_PREFIXES:
if path.startswith(prefix):
should_log = response.status_code >= 400 or duration_ms > 5000
break
# Check quiet paths - only log errors or slow requests
for quiet_path in self.QUIET_PATHS:
if path.startswith(quiet_path):

View File

@@ -86,6 +86,24 @@ def setup_logging():
):
logging.getLogger(noisy_logger).setLevel(logging.WARNING)
# Filter uvicorn access log — suppress GET polling endpoints
import re as _re
_POLL_RE = _re.compile(
r'"GET /(proxmox/api/|settings/api/panel|api/notifications/unread-count|proxmox/api/servers)'
)
class _UvicornAccessFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
# Allow all non-200 responses through
if '" 2' not in msg:
return True
return not bool(_POLL_RE.search(msg))
for uvicorn_logger_name in ("uvicorn.access", "uvicorn"):
uvicorn_logger = logging.getLogger(uvicorn_logger_name)
uvicorn_logger.addFilter(_UvicornAccessFilter())
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -212,7 +230,7 @@ def create_app() -> FastAPI:
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://esm.sh; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.googleapis.com; "
"img-src 'self' data: blob:; "
"connect-src 'self' wss: ws: https://esm.sh; "
"connect-src 'self' wss: ws: https://esm.sh https://api.github.com; "
"font-src 'self' data: https://cdnjs.cloudflare.com https://fonts.gstatic.com; "
"frame-ancestors 'self';"
)

View File

@@ -86,9 +86,9 @@ class ProxmoxClient:
verify_ssl=verify_ssl,
timeout=timeout
)
logger.info(f"Connected to Proxmox {host} using password")
logger.info("Connected to Proxmox using password")
else:
logger.error(f"Необходимо указать либо password, либо token для {host}")
logger.error("Необходимо указать либо password, либо token")
return
# Test connection

View File

@@ -46,6 +46,21 @@ const translations = {
ssh_key_saved: "SSH ключ успешно сохранён",
error_saving_ssh_key: "Ошибка сохранения SSH ключа",
invalid_ssh_key_format: "Неверный формат SSH ключа",
// Dashboard
nodes_label: "Ноды",
clusters_label: "Кластеры",
alerts_label: "Оповещения",
vms: "ВМ",
running: "Запущено",
online: "Онлайн",
paused: "Пауза",
stopped: "Остановлен",
loading_dots: "Загрузка…",
no_vms_found: "Нет виртуальных машин",
no_nodes: "Нет доступных нод",
all_healthy: "Все работают",
no_alerts: "Нет оповещений",
},
en: {
@@ -90,6 +105,21 @@ const translations = {
ssh_key_saved: "SSH key saved successfully",
error_saving_ssh_key: "Error saving SSH key",
invalid_ssh_key_format: "Invalid SSH key format",
// Dashboard
nodes_label: "Nodes",
clusters_label: "Clusters",
alerts_label: "Alerts",
vms: "VMs",
running: "Running",
online: "Online",
paused: "Paused",
stopped: "Stopped",
loading_dots: "Loading…",
no_vms_found: "No VMs found",
no_nodes: "No nodes available",
all_healthy: "All healthy",
no_alerts: "No alerts",
}
};

View File

@@ -10,12 +10,17 @@
<h1 class="page-header-title"><i class="fa-solid fa-hard-drive"></i> {{ t('nav_backups') }}</h1>
</div>
<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 %}
<option value="{{ s.id }}" data-name="{{ s.name }}">{{ s.name }}</option>
{% endfor %}
</select>
<div class="d-flex align-items-center gap-2">
<label for="serverSelect" class="form-label mb-0 text-nowrap fw-semibold">
<i class="fa-solid fa-server me-1"></i>{{ t('server') }}:
</label>
<select id="serverSelect" class="form-select form-select-sm" style="min-width:260px" onchange="onServerChange()">
<option value="">{{ t('select_server') }}</option>
{% for s in servers %}
<option value="{{ s.id }}" data-name="{{ s.name }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
</div>
</div>

View File

@@ -636,7 +636,7 @@ body.update-in-progress .layout {
<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">
<a href="https://github.com/markmorado/pvemanager" target="_blank" rel="noopener noreferrer" class="github-btn" title="Star us on GitHub">
<i class="fa-brands fa-github"></i>
<span class="github-text">pvemanager</span>
<svg class="github-star-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
@@ -920,9 +920,9 @@ body.update-in-progress .layout {
// Load GitHub stars count
async function loadGitHubStars() {
try {
const response = await fetch('https://git.tzim.uz/api/v1/repos/markmorado/pvemanager', {
const response = await fetch('https://api.github.com/repos/markmorado/pvemanager', {
headers: {
'Accept': 'application/json'
'Accept': 'application/vnd.github+json'
}
});
@@ -931,7 +931,7 @@ body.update-in-progress .layout {
const starsElement = document.getElementById('githubStars');
if (starsElement) {
// Format number with K for thousands
const stars = data.stars_count;
const stars = data.stargazers_count;
const formatted = stars >= 1000
? (stars / 1000).toFixed(1) + 'K'
: stars.toString();

View File

@@ -12,15 +12,15 @@
{% 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">
<div class="status-chip {% if stats.total_alerts == 0 %}status-ok{% elif stats.critical_alerts > 0 %}status-error{% else %}status-warning{% endif %}" id="dashStatusChip"{% if stats.total_alerts > 0 %} onclick="openAlertsModal()" style="cursor:pointer;" title="{{ t('view_alerts') if t('view_alerts') else 'Посмотреть предупреждения' }}"{% endif %}>
<span class="status-chip-dot"></span>
<span id="dashStatusText">
{%- if stats.total_alerts == 0 -%}
All systems operational
{{ t('all_systems_operational') }}
{%- elif stats.critical_alerts > 0 -%}
{{ stats.critical_alerts }} critical alert{{ 's' if stats.critical_alerts != 1 else '' }}
{{ stats.critical_alerts }} {{ t('critical') | lower }} {{ t('alerts_label') | lower }}
{%- else -%}
{{ stats.total_alerts }} warning{{ 's' if stats.total_alerts != 1 else '' }}
{{ stats.total_alerts }} {{ t('warning') | lower }}{{ 's' if stats.total_alerts != 1 else '' }}
{%- endif -%}
</span>
</div>
@@ -31,55 +31,62 @@
<!-- ======== 5 STAT CARDS ======== -->
<div class="stats-grid-5">
<div class="stat-card-v2">
<div class="stat-card-lbl">Nodes</div>
<div class="stat-card-lbl">{{ t('nodes_label') }}</div>
<div class="stat-card-val">{{ stats.total_servers }}</div>
<div class="stat-card-sub" id="statNodesSub">
{%- if stats.online_servers == stats.total_servers and stats.total_servers > 0 -%}
All online
{{ t('all_online') }}
{%- elif stats.online_servers > 0 -%}
{{ stats.online_servers }} online
{{ stats.online_servers }} {{ t('online') | lower }}
{%- else -%}
—
{%- endif -%}
</div>
</div>
<div class="stat-card-v2">
<div class="stat-card-lbl">VMs</div>
<div class="stat-card-lbl">{{ t('vms') }}</div>
<div class="stat-card-val" id="statVmCount">—</div>
<div class="stat-card-sub" id="statVmSub">loading…</div>
<div class="stat-card-sub" id="statVmSub">{{ t('loading') }}…</div>
</div>
<div class="stat-card-v2">
<div class="stat-card-lbl">Containers</div>
<div class="stat-card-lbl">{{ t('containers') }}</div>
<div class="stat-card-val" id="statCtCount">—</div>
<div class="stat-card-sub" id="statCtSub">loading…</div>
<div class="stat-card-sub" id="statCtSub">{{ t('loading') }}…</div>
</div>
<div class="stat-card-v2">
<div class="stat-card-lbl">Clusters</div>
<div class="stat-card-lbl">{{ t('clusters_label') }}</div>
<div class="stat-card-val">{{ stats.total_clusters }}</div>
<div class="stat-card-sub" id="statClusterSub">
{%- if stats.total_alerts == 0 -%}All healthy{%- else -%}—{%- endif -%}
{%- if stats.total_alerts == 0 -%}{{ t('all_healthy') }}{%- else -%}—{%- endif -%}
</div>
</div>
<div class="stat-card-v2 {% if stats.critical_alerts > 0 %}card-alerts{% endif %}">
<div class="stat-card-lbl">Alerts</div>
<div class="stat-card-lbl">{{ t('alerts_label') }}</div>
<div class="stat-card-val">{{ stats.total_alerts }}</div>
<div class="stat-card-sub">
{%- if stats.critical_alerts > 0 -%}
<span style="color: var(--danger);">{{ stats.critical_alerts }} critical</span>
<span style="color: var(--danger);">{{ stats.critical_alerts }} {{ t('critical') | lower }}</span>
{%- elif stats.total_alerts > 0 -%}
last 24h
{{ t('last_24h') }}
{%- else -%}
No alerts
{{ t('no_alerts') }}
{%- endif -%}
</div>
</div>
</div>
<!-- ======== RESOURCE CARDS (CPU / RAM / STORAGE) ======== -->
<div style="position:relative;">
<div class="resource-cards-row" id="resourceCardsRow">
<!-- refresh spinner (shown while fetching) -->
<span id="dashRefreshSpin" title="{{ t('loading') }}" style="
position:absolute; top:-1.6rem; right:0;
font-size:.75rem; color:var(--text-muted); opacity:0;
transition:opacity .2s; pointer-events:none;
"><i class="fa-solid fa-arrows-rotate fa-spin" style="margin-right:.3rem;"></i><span id="dashLastUpdated"></span></span>
<div class="resource-card">
<div class="resource-card-header">
<span class="resource-card-title">CPU Usage</span>
<span class="resource-card-title">{{ t('cpu_usage') }}</span>
<span class="resource-pct-badge" id="cpuPct">—</span>
</div>
<div class="resource-mini-chart">
@@ -91,7 +98,7 @@
</div>
<div class="resource-card">
<div class="resource-card-header">
<span class="resource-card-title">RAM Usage</span>
<span class="resource-card-title">{{ t('ram_usage') }}</span>
<span class="resource-pct-badge" id="ramPct">—</span>
</div>
<div class="resource-mini-chart">
@@ -103,7 +110,7 @@
</div>
<div class="resource-card">
<div class="resource-card-header">
<span class="resource-card-title">Storage</span>
<span class="resource-card-title">{{ t('storage') }}</span>
<span class="resource-pct-badge" id="diskPct">—</span>
</div>
<div class="resource-mini-chart">
@@ -114,32 +121,33 @@
</div>
</div>
</div>
</div>
<!-- ======== VM TABLE + NODES PANEL ======== -->
<div class="dash-main-grid">
<!-- VM Table -->
<div class="dash-section">
<div class="dash-section-header">
<span class="dash-section-title">Virtual Machines</span>
<span class="dash-section-title">{{ t('virtual_machines') }}</span>
<div style="display:flex; gap:0.75rem; align-items:center;">
<span class="dash-section-meta" id="vmTableMeta">—</span>
<a href="/virtual-machines" class="dash-section-link">View all →</a>
<a href="/virtual-machines" class="dash-section-link">{{ t('view_all') }}</a>
</div>
</div>
<div style="overflow-x: auto;">
<table class="vm-table-compact">
<thead>
<tr>
<th>Name</th>
<th>Node</th>
<th>CPU</th>
<th>RAM</th>
<th>Status</th>
<th>{{ t('name') }}</th>
<th>{{ t('node') }}</th>
<th>{{ t('cpu') }}</th>
<th>{{ t('ram') }}</th>
<th>{{ t('status') }}</th>
</tr>
</thead>
<tbody id="vmTableBody">
<tr><td colspan="5" style="text-align:center; color:var(--text-muted); padding:2rem 1.25rem;">
<i class="fa-solid fa-circle-notch fa-spin" style="margin-right:.5rem;"></i> Loading…
<i class="fa-solid fa-circle-notch fa-spin" style="margin-right:.5rem;"></i> {{ t('loading') }}…
</td></tr>
</tbody>
</table>
@@ -149,12 +157,12 @@
<!-- Nodes Panel -->
<div class="dash-section">
<div class="dash-section-header">
<span class="dash-section-title">Nodes</span>
<span class="dash-section-title">{{ t('nodes_label') }}</span>
<span class="dash-section-meta" id="nodesOnlineMeta">—</span>
</div>
<div class="nodes-panel" id="nodesPanel">
<div style="text-align:center; color:var(--text-muted); padding:2rem 1.25rem;">
<i class="fa-solid fa-circle-notch fa-spin" style="margin-right:.5rem;"></i> Loading…
<i class="fa-solid fa-circle-notch fa-spin" style="margin-right:.5rem;"></i> {{ t('loading') }}…
</div>
</div>
</div>
@@ -163,8 +171,8 @@
<!-- ======== 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>
<span class="dash-section-title">{{ t('recent_audit_events') }}</span>
<a href="/logs" class="dash-section-link">{{ t('view_all') }}</a>
</div>
<div class="audit-events-list" id="auditEventsList">
{% if recent_audit %}
@@ -184,7 +192,7 @@
{% endfor %}
{% else %}
<div style="text-align:center; color:var(--text-muted); padding:1.5rem 1.25rem; font-size:0.875rem;">
No audit events found
{{ t('no_audit_events') }}
</div>
{% endif %}
</div>
@@ -219,13 +227,18 @@ function setPctBadgeColor(el, pct) {
else el.classList.add('pct-high');
}
// Draw a mini line chart on a canvas
// Draw a mini line chart on a canvas — updates in-place on subsequent calls
function drawMiniLineChart(canvasId, dataPoints, color, bgColor) {
if (typeof Chart === 'undefined') return;
const canvas = document.getElementById(canvasId);
if (!canvas) return;
// destroy existing
if (canvas._chartInstance) { canvas._chartInstance.destroy(); }
if (canvas._chartInstance) {
// Update data in-place (no flicker, smooth transition)
canvas._chartInstance.data.labels = dataPoints.map((_, i) => i);
canvas._chartInstance.data.datasets[0].data = dataPoints;
canvas._chartInstance.update('active');
return;
}
const ctx = canvas.getContext('2d');
canvas._chartInstance = new Chart(ctx, {
type: 'line',
@@ -244,7 +257,7 @@ function drawMiniLineChart(canvasId, dataPoints, color, bgColor) {
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 600 },
animation: { duration: 400 },
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: {
x: { display: false },
@@ -254,12 +267,17 @@ function drawMiniLineChart(canvasId, dataPoints, color, bgColor) {
});
}
// Draw a mini bar chart (for storage)
// Draw a mini bar chart (for storage) — updates in-place on subsequent calls
function drawMiniBarChart(canvasId, dataPoints, color) {
if (typeof Chart === 'undefined') return;
const canvas = document.getElementById(canvasId);
if (!canvas) return;
if (canvas._chartInstance) { canvas._chartInstance.destroy(); }
if (canvas._chartInstance) {
canvas._chartInstance.data.labels = dataPoints.map((_, i) => i);
canvas._chartInstance.data.datasets[0].data = dataPoints;
canvas._chartInstance.update('active');
return;
}
const ctx = canvas.getContext('2d');
canvas._chartInstance = new Chart(ctx, {
type: 'bar',
@@ -275,7 +293,7 @@ function drawMiniBarChart(canvasId, dataPoints, color) {
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 600 },
animation: { duration: 400 },
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: {
x: { display: false },
@@ -285,11 +303,182 @@ function drawMiniBarChart(canvasId, dataPoints, color) {
});
}
// Load all resources to populate VMs stat card and VM table
async function loadVMStats() {
// Refresh spinner helpers
function showRefreshSpin(visible) {
const el = document.getElementById('dashRefreshSpin');
if (el) el.style.opacity = visible ? '1' : '0';
}
function markLastUpdated() {
const el = document.getElementById('dashLastUpdated');
if (!el) return;
const now = new Date();
el.textContent = now.toLocaleTimeString(
'{{ "ru-RU" if lang == "ru" else "en-US" }}',
{ hour: '2-digit', minute: '2-digit', second: '2-digit' }
);
}
// Inner function: fetch node stats for already-known online servers
async function _refreshNodeStats(onlineServers) {
try {
// ---------- Step 1: fetch node lists for all servers IN PARALLEL ----------
const nodeListResults = await Promise.all(
onlineServers.map(async server => {
// server here may be from /resources/all (has .id) or /servers (has .id + .is_online)
try {
const r = await fetchWithAuth(`/proxmox/api/${server.id}/nodes`, { hideOnForbidden: true });
if (!r || !r.ok) return null;
const d = await r.json();
return { server, nodes: d.nodes || [], clusterId: d.cluster_id || `standalone-${server.id}` };
} catch (e_) { return null; }
})
);
// de-duplicate across clusters
const seenNodes = new Set();
const uniqueNodes = [];
for (const result of nodeListResults) {
if (!result) continue;
for (const nodeInfo of result.nodes) {
const key = `${result.clusterId}::${nodeInfo.node}`;
if (seenNodes.has(key)) continue;
seenNodes.add(key);
uniqueNodes.push({ server: result.server, nodeName: nodeInfo.node, maxVms: nodeInfo.maxvms || 0 });
}
}
if (uniqueNodes.length === 0) return;
// ---------- Step 2: fetch status AND rrd for ALL nodes IN PARALLEL ----------
const [statusResults, rrdResults] = await Promise.all([
Promise.all(uniqueNodes.map(async ({ server, nodeName, maxVms }) => {
try {
const r = await fetchWithAuth(`/proxmox/api/${server.id}/node/status?node=${nodeName}`, { hideOnForbidden: true });
if (!r || !r.ok) return { nodeName, cpuPct: 0, ramPct: 0, diskPct: 0, vmCount: maxVms };
const st = await r.json();
return {
nodeName, vmCount: maxVms,
cpuPct: Math.round((st.cpu || 0) * 100),
ramPct: st.memory && st.memory.total > 0 ? Math.round((st.memory.used / st.memory.total) * 100) : 0,
diskPct: st.rootfs && st.rootfs.total > 0 ? Math.round((st.rootfs.used / st.rootfs.total) * 100) : 0,
};
} catch (e_) { return { nodeName, cpuPct: 0, ramPct: 0, diskPct: 0, vmCount: maxVms }; }
})),
Promise.all(uniqueNodes.map(async ({ server, nodeName }) => {
try {
const r = await fetchWithAuth(`/proxmox/api/${server.id}/node/rrddata?node=${nodeName}&timeframe=hour`, { hideOnForbidden: true });
if (!r || !r.ok) return null;
const d = await r.json();
return d.data || [];
} catch (e_) { return null; }
}))
]);
const allNodeData = statusResults.filter(Boolean);
// Aggregate RRD histories
const cpuHistoryAll = [], ramHistoryAll = [], diskHistoryAll = [];
for (const data of rrdResults) {
if (!data || data.length === 0) continue;
const nc = [], nr = [], nd = [];
for (const pt of data) {
nc.push((pt.cpu || 0) * 100);
nr.push(pt.memtotal > 0 ? ((pt.memused || 0) / pt.memtotal * 100) : (pt.mem || 0) * 100);
nd.push(pt.roottotal > 0 ? ((pt.rootused || 0) / pt.roottotal * 100) : 0);
}
if (nc.length > 0) { cpuHistoryAll.push(nc); ramHistoryAll.push(nr); diskHistoryAll.push(nd); }
}
// ---------- Render resource cards ----------
if (allNodeData.length > 0) {
const avgCpu = Math.round(allNodeData.reduce((s, n) => s + n.cpuPct, 0) / allNodeData.length);
const avgRam = Math.round(allNodeData.reduce((s, n) => s + n.ramPct, 0) / allNodeData.length);
const avgDisk = Math.round(allNodeData.reduce((s, n) => s + n.diskPct, 0) / allNodeData.length);
const cpuPctEl = document.getElementById('cpuPct');
const ramPctEl = document.getElementById('ramPct');
const diskPctEl = document.getElementById('diskPct');
if (cpuPctEl) { cpuPctEl.textContent = avgCpu + '%'; setPctBadgeColor(cpuPctEl, avgCpu); document.getElementById('cpuBar').style.width = avgCpu + '%'; }
if (ramPctEl) { ramPctEl.textContent = avgRam + '%'; setPctBadgeColor(ramPctEl, avgRam); document.getElementById('ramBar').style.width = avgRam + '%'; }
if (diskPctEl) { diskPctEl.textContent = avgDisk + '%'; setPctBadgeColor(diskPctEl, avgDisk); document.getElementById('diskBar').style.width = avgDisk + '%'; }
}
// Mini charts
if (cpuHistoryAll.length > 0) {
const len = Math.max(...cpuHistoryAll.map(a => a.length));
const avgH = arrays => Array.from({ length: len }, (_, i) => {
const vals = arrays.map(a => i < a.length ? a[i] : (a[a.length - 1] || 0));
return vals.reduce((s, v) => s + v, 0) / vals.length;
});
const cpuSlice = avgH(cpuHistoryAll).slice(-24);
const ramSlice = avgH(ramHistoryAll).slice(-24);
const diskSlice = avgH(diskHistoryAll).slice(-24);
requestAnimationFrame(() => {
drawMiniLineChart('cpuMiniChart', cpuSlice, '#3b82f6', 'rgba(59,130,246,0.15)');
drawMiniLineChart('ramMiniChart', ramSlice, '#8b5cf6', 'rgba(139,92,246,0.15)');
if (diskSlice.some(v => v > 0)) {
drawMiniBarChart('diskMiniChart', diskSlice, 'rgba(16,185,129,0.7)');
} else {
const avgD = allNodeData.length > 0 ? Math.round(allNodeData.reduce((s, n) => s + n.diskPct, 0) / allNodeData.length) : 0;
drawMiniBarChart('diskMiniChart', Array(24).fill(0).map((_, i) => (i / 23) * avgD * 0.9 + avgD * 0.1), 'rgba(16,185,129,0.7)');
}
});
}
// ---------- Render Nodes panel ----------
const panel = document.getElementById('nodesPanel');
const onlineMeta = document.getElementById('nodesOnlineMeta');
if (onlineMeta) onlineMeta.textContent = allNodeData.length + '/' + allNodeData.length + ' ' + t('online').toLowerCase();
if (panel) {
if (allNodeData.length === 0) {
panel.innerHTML = `<div style="text-align:center;color:var(--text-muted);padding:2rem 1.25rem;font-size:.875rem;">${t('no_nodes')}</div>`;
} else {
panel.innerHTML = allNodeData.map(node => `
<div class="node-item">
<div class="node-item-header">
<div class="node-item-name">
<span class="node-dot-online"></span>
${escapeHtml(node.nodeName)}
</div>
<span class="node-vm-count">${node.vmCount > 0 ? node.vmCount + ' ' + t('vms') : ''}</span>
</div>
<div class="node-bar-row">
<span class="node-bar-lbl">CPU</span>
<div class="node-bar-wrap"><div class="node-bar-fill cpu" style="width:${node.cpuPct}%"></div></div>
<span class="node-bar-pct">${node.cpuPct}%</span>
</div>
<div class="node-bar-row">
<span class="node-bar-lbl">RAM</span>
<div class="node-bar-wrap"><div class="node-bar-fill ram" style="width:${node.ramPct}%"></div></div>
<span class="node-bar-pct">${node.ramPct}%</span>
</div>
</div>
`).join('');
}
}
} catch (e) {
// silent
}
}
// HTML escape helper
function escapeHtml(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
// Two-speed refresh:
// • refreshVMs() — every refresh_interval (fast, 1 request)
// • _refreshNodeStats — max once per 60 s (slow, many requests)
let _lastNodeRefresh = 0;
const NODE_REFRESH_MIN_MS = 60000; // node charts refresh at most every 60 s
async function refreshVMs() {
showRefreshSpin(true);
try {
const res = await fetchWithAuth('/proxmox/api/resources/all', { hideOnForbidden: true });
if (!res || !res.ok || res.forbidden) return;
if (!res || !res.ok || res.forbidden) { showRefreshSpin(false); return; }
const data = await res.json();
let totalVms = 0, runningVms = 0, totalCt = 0, runningCt = 0;
@@ -302,26 +491,21 @@ async function loadVMStats() {
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 });
}
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 vmSub = document.getElementById('statVmSub');
const ctCount = document.getElementById('statCtCount');
const ctSub = document.getElementById('statCtSub');
const ctSub = document.getElementById('statCtSub');
if (vmCount) vmCount.textContent = totalVms;
if (vmSub) vmSub.textContent = runningVms + ' running';
if (vmSub) vmSub.textContent = runningVms + ' ' + t('running').toLowerCase();
if (ctCount) ctCount.textContent = totalCt;
if (ctSub) ctSub.textContent = runningCt + ' running';
if (ctSub) ctSub.textContent = runningCt + ' ' + t('running').toLowerCase();
// 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';
const meta = document.getElementById('vmTableMeta');
if (meta) meta.textContent = totalVms + ' ' + t('total');
if (tbody) {
allVms.sort((a, b) => {
const rank = s => s === 'running' ? 0 : s === 'paused' ? 1 : 2;
@@ -329,14 +513,14 @@ async function loadVMStats() {
});
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>';
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; color:var(--text-muted); padding:2rem 1.25rem;">${t('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 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);
const statusCls = status === 'running' ? 'running' : status === 'paused' ? 'warning' : 'stopped';
const statusLabel = status === 'running' ? t('running') : status === 'paused' ? t('paused') : t('stopped');
return `<tr>
<td><span class="vm-name-cell">${escapeHtml(vm.name || 'vm-' + vm.vmid)}</span></td>
<td><span class="vm-node-cell">${escapeHtml(vm.node || '—')}</span></td>
@@ -347,184 +531,116 @@ async function loadVMStats() {
}).join('');
}
}
// Trigger node chart refresh if enough time has passed
const now = Date.now();
if (now - _lastNodeRefresh >= NODE_REFRESH_MIN_MS) {
_lastNodeRefresh = now;
const onlineServers = (data.servers || []).filter(s => !s.error && s.id);
if (onlineServers.length > 0) {
_refreshNodeStats(onlineServers).then(() => {
markLastUpdated();
});
}
}
markLastUpdated();
} catch (e) {
console.error('VM stats error:', e);
// silent
} finally {
showRefreshSpin(false);
}
}
// Load node data and resource charts + nodes panel
async function loadNodeStats() {
// Init — reads refresh_interval from panel settings, falls back to 10s
document.addEventListener('DOMContentLoaded', async function() {
// Initial full load (VMs + nodes)
showRefreshSpin(true);
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);
const res = await fetchWithAuth('/proxmox/api/resources/all', { hideOnForbidden: true });
if (res && res.ok && !res.forbidden) {
const data = await res.json();
const onlineServers = (data.servers || []).filter(s => !s.error && s.id);
if (onlineServers.length > 0) {
_lastNodeRefresh = Date.now();
await _refreshNodeStats(onlineServers);
}
}
} catch (e) {/* ignore */}
await refreshVMs();
// === 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('');
let intervalMs = 10000; // default 10s
try {
const r = await fetchWithAuth('/settings/api/panel');
if (r && r.ok) {
const cfg = await r.json();
if (cfg.refresh_interval && cfg.refresh_interval > 0) {
intervalMs = cfg.refresh_interval * 1000;
}
}
} catch (e) {/* use default */}
setInterval(refreshVMs, intervalMs);
});
// ======== Alerts Modal ========
function closeAlertsModal() {
document.getElementById('alertsModal').style.display = 'none';
}
async function openAlertsModal() {
const modal = document.getElementById('alertsModal');
modal.style.display = 'flex';
const body = document.getElementById('alertsModalBody');
body.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:2rem;"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
try {
const res = await fetchWithAuth('/api/dashboard/alerts');
if (!res || !res.ok) throw new Error('failed');
const data = await res.json();
if (!data.alerts || data.alerts.length === 0) {
body.innerHTML = `<div style="text-align:center;color:var(--text-muted);padding:2rem;">${t('no_alerts')}</div>`;
return;
}
const locale = '{{ "ru-RU" if lang == "ru" else "en-US" }}';
body.innerHTML = data.alerts.map(a => {
const time = a.created_at
? new Date(a.created_at).toLocaleString(locale, {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'})
: '—';
const lvlCls = a.level === 'critical' ? 'critical' : 'error';
return `<div class="audit-row" style="align-items:flex-start;padding:.65rem 0;border-bottom:1px solid var(--border, rgba(255,255,255,.06));">
<span class="audit-time" style="flex-shrink:0;">${time}</span>
<span style="flex-shrink:0;"><span class="audit-action-tag ${lvlCls}">${escapeHtml(a.level)}</span></span>
<span style="flex:1;font-size:.875rem;color:var(--text-primary);word-break:break-word;">${escapeHtml(a.message)}</span>
${a.username ? `<span class="audit-user" style="flex-shrink:0;">${escapeHtml(a.username)}</span>` : '<span></span>'}
</div>`;
}).join('');
} catch (e) {
console.error('Node stats error:', e);
body.innerHTML = '<div style="text-align:center;color:var(--danger);padding:2rem;"><i class="fa-solid fa-circle-exclamation" style="margin-right:.5rem;"></i>Ошибка загрузки</div>';
}
}
</script>
// 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() {
loadVMStats();
loadNodeStats();
<!-- ======== Alerts Modal ======== -->
<div id="alertsModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.82);z-index:2000;align-items:center;justify-content:center;">
<div class="card" style="max-width:680px;width:95%;max-height:82vh;display:flex;flex-direction:column;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
<h3 style="margin:0;color:var(--text-primary);">
<i class="fa-solid fa-triangle-exclamation" style="color:var(--warning,#f59e0b);margin-right:.5rem;"></i>
{{ t('alerts_label') }} — {{ t('last_24h') }}
</h3>
<button onclick="closeAlertsModal()" style="background:none;border:none;color:var(--text-muted);font-size:1.5rem;cursor:pointer;line-height:1;">&times;</button>
</div>
<div id="alertsModalBody" style="overflow-y:auto;flex:1;min-height:80px;"></div>
<div style="margin-top:.75rem;padding-top:.75rem;border-top:1px solid var(--border, rgba(255,255,255,.06));text-align:right;">
<a href="/logs?level=error" class="btn btn-secondary" style="text-decoration:none;">
<i class="fa-solid fa-arrow-right" style="margin-right:.5rem;"></i>{{ t('view_all') }}
</a>
</div>
</div>
</div>
<script>
document.getElementById('alertsModal').addEventListener('click', function(e) {
if (e.target === this) closeAlertsModal();
});
</script>
{% endblock %}

View File

@@ -203,9 +203,9 @@
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: rgba(56, 189, 248, 0.1);
background: var(--info-light);
color: var(--info);
border: 1px solid rgba(56, 189, 248, 0.4);
border: 1px solid var(--info);
border-radius: 0.375rem;
font-size: 0.7rem;
font-weight: 600;
@@ -457,7 +457,7 @@
</div>
{% if proxmox_servers | length <= 2 %}
<div style="background: rgba(234, 179, 8, 0.1); border: 1px solid var(--warning); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1.5rem;">
<div style="background: var(--warning-light); border: 1px solid var(--warning); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1.5rem;">
<div style="display: flex; align-items: start; gap: 0.75rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--warning); margin-top: 2px;"></i>
<div style="font-size: 0.875rem; color: var(--text-primary);">
@@ -494,14 +494,14 @@
</div>
<!-- Warning: no cluster servers -->
<div id="joinNoClusterWarning" style="display: none; background: rgba(234, 179, 8, 0.1); border: 1px solid var(--warning); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1rem; font-size: 0.875rem;">
<div id="joinNoClusterWarning" style="display: none; background: var(--warning-light); border: 1px solid var(--warning); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1rem; font-size: 0.875rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--warning);"></i>
Нет серверов с активным кластером. Сначала <strong>создайте кластер</strong> на одном из серверов.
</div>
<!-- Step 1: Select nodes -->
<div id="joinStep1">
<div style="background: rgba(56, 189, 248, 0.1); border: 1px solid var(--info); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1.2rem; font-size: 0.875rem;">
<div style="background: var(--info-light); border: 1px solid var(--info); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1.2rem; font-size: 0.875rem;">
<i class="fa-solid fa-circle-info" style="color: var(--info);"></i>
Выберите ноду для добавления в кластер и любую существующую ноду кластера.
</div>
@@ -572,7 +572,7 @@
<!-- Step 4: Final join -->
<div id="joinStep4" style="display: none;">
<div style="background: rgba(234, 179, 8, 0.1); border: 1px solid var(--warning); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1.2rem; font-size: 0.875rem;">
<div style="background: var(--warning-light); border: 1px solid var(--warning); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1.2rem; font-size: 0.875rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--warning);"></i>
После присоединения нода синхронизирует конфигурацию с кластером. Операция необратима.
</div>
@@ -611,7 +611,7 @@
<button onclick="closeEjectNodeModal()" style="background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1.5rem; font-size: 0.875rem;">
<div style="background: var(--danger-light); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1.5rem; font-size: 0.875rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--danger);"></i>
<strong> Внимание!</strong> Исключаемая нода должна быть <strong>полностью выключена</strong> до запуска операции.
После исключения на ней необходимо вручную сбросить конфигурацию кластера.
@@ -1876,7 +1876,7 @@ async function loadVNC(serverId, node, vmid, type) {
</ol>
</div>
<div style="margin-top: 1.5rem; padding: 1rem; background: rgba(255,193,7,0.1); border: 1px solid rgba(255,193,7,0.3); border-radius: 6px;">
<div style="margin-top: 1.5rem; padding: 1rem; background: var(--warning-light); border: 1px solid var(--warning); border-radius: 6px;">
<p style="color: var(--warning); font-size: 0.875rem; margin: 0;">
<strong><i class="fa-solid fa-triangle-exclamation"></i> Примечание:</strong> Встроенный VNC в iframe невозможен из-за политики безопасности браузера и Proxmox CORS.
</p>
@@ -2617,7 +2617,7 @@ function renderPreCheckResult(data) {
if (data.guests_count === 0) {
container.innerHTML = `
<div style="background: rgba(34, 197, 94, 0.1); border: 1px solid var(--success); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<div style="background: var(--success-light); border: 1px solid var(--success); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<i class="fa-solid fa-circle-check" style="color: var(--success);"></i>
<strong> Нода чистая!</strong> VM и LXC не обнаружены. Можно сразу присоединяться к кластеру.
</div>
@@ -2644,7 +2644,7 @@ function renderPreCheckResult(data) {
`).join('');
container.innerHTML = `
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<div style="background: var(--danger-light); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--danger);"></i>
<strong> Обнаружено гестов: ${data.guests_count}</strong><br>
<span style="font-size: 0.875rem; color: var(--text-secondary);">
@@ -2673,7 +2673,7 @@ function renderPreCheckResult(data) {
} else {
document.getElementById('joinBackupStorage').innerHTML = '<option value="">Нет доступных хранилищ backup</option>';
container.innerHTML += `
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1rem; font-size: 0.875rem;">
<div style="background: var(--danger-light); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1rem; font-size: 0.875rem;">
<i class="fa-solid fa-exclamation-circle" style="color: var(--danger);"></i>
<strong> Нет хранилищ для бэкапов.</strong> Добавьте NFS/CIFS/Ceph хранилище с типом backup.
</div>