feat: v1.1.3 — dashboard alerts panel, sparkline charts, log sanitisation, CSS vars
This commit is contained in:
30
CHANGELOG.md
30
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Modern web panel for managing Proxmox servers, virtual machines and LXC containers**
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](LICENSE)
|
||||
[](compose.yml)
|
||||
[](backend/)
|
||||
|
||||
64
WIKI.md
64
WIKI.md
@@ -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*
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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';"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;">×</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 %}
|
||||
|
||||
@@ -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;">×</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>
|
||||
|
||||
Reference in New Issue
Block a user