feat: real-time WebSocket metrics broadcaster and UI updates

- New metrics_broadcaster service (410 lines) — background broadcaster
  pushing CPU/RAM/disk/network stats to subscribed WebSocket clients
- WebSocket protocol extended: subscribe/unsubscribe channels,
  get_rrd/get_node_rrd requests with request_id
- main.py: metrics broadcaster started in lifespan, WS message dispatcher
- websocket_manager.py: subscription map support
- templates: dashboard, instance_detail, proxmox_server_detail,
  proxmox_vms, workspaces — real-time charts and UI updates
- auth.py, servers.py, settings.py: minor fixes
This commit is contained in:
markmorado
2026-03-15 02:49:38 +01:00
parent c4778d2972
commit ed7f1f779e
14 changed files with 1203 additions and 167 deletions

View File

@@ -216,6 +216,26 @@ def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_
}
@router.post("/api/auth/refresh", response_model=Token)
def refresh_token(
current_user: User = Depends(get_current_user),
session_token: str = Depends(get_current_session_token),
db: Session = Depends(get_db)
):
"""Refresh JWT access token without re-login (extends session)"""
session_timeout = SecurityService.get_setting_int(db, "session_timeout_minutes", 60)
access_token_expires = timedelta(minutes=session_timeout)
new_token = create_access_token(
data={"sub": current_user.username},
session_token=session_token,
expires_delta=access_token_expires
)
return {
"access_token": new_token,
"token_type": "bearer"
}
@router.post("/api/auth/logout")
def logout(
request: Request,

View File

@@ -33,9 +33,11 @@ def vms_page(request: Request, db: Session = Depends(get_db)):
"""Страница управления Proxmox серверами, VM и LXC"""
from ...i18n import t
lang = request.cookies.get("language", "en")
# All servers are needed for cluster modals (admin operations).
# The main servers table is rendered dynamically via JS with workspace filtering.
proxmox_servers = db.query(ProxmoxServer).all()
context = {
"request": request,
"proxmox_servers": proxmox_servers,

View File

@@ -75,13 +75,11 @@ class ChangePasswordRequest(BaseModel):
class PanelSettingsResponse(BaseModel):
panel_name: str
refresh_interval: int # seconds
log_retention_days: int
language: str # ru or en
class UpdatePanelSettingsRequest(BaseModel):
refresh_interval: Optional[int] = Field(None, ge=1, le=60)
log_retention_days: Optional[int] = Field(None, ge=1, le=365)
language: Optional[str] = Field(None, pattern='^(ru|en)$')
@@ -269,13 +267,11 @@ async def get_panel_settings(
from ..config import settings
# Get settings from database or use defaults
refresh_interval = get_setting(db, "refresh_interval", "5")
log_retention_days = get_setting(db, "log_retention_days", "30")
language = get_setting(db, "language", "ru")
return {
"panel_name": settings.PANEL_NAME,
"refresh_interval": int(refresh_interval),
"log_retention_days": int(log_retention_days),
"language": language
}
@@ -291,10 +287,6 @@ async def update_panel_settings(
updated_fields = []
# Save settings to database
if data.refresh_interval is not None:
set_setting(db, "refresh_interval", str(data.refresh_interval), "Интервал обновления данных (секунды)")
updated_fields.append(f"refresh_interval={data.refresh_interval}")
if data.log_retention_days is not None:
set_setting(db, "log_retention_days", str(data.log_retention_days), "Срок хранения логов (дни)")
updated_fields.append(f"log_retention_days={data.log_retention_days}")

View File

@@ -166,6 +166,14 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"Backup scheduler startup failed: {e}")
# Start WebSocket metrics broadcaster
try:
from .services.metrics_broadcaster import start_metrics_broadcaster
from .db import SessionLocal
start_metrics_broadcaster(SessionLocal)
except Exception as e:
logger.warning(f"Metrics broadcaster startup failed: {e}")
logger.info("Application startup complete")
yield
@@ -335,6 +343,7 @@ def create_app() -> FastAPI:
from .db import SessionLocal
from .models import TaskQueue, ProxmoxTask, User
from .websocket_manager import ws_manager
import asyncio
import json
# --- Authenticate --------------------------------------------------
@@ -382,9 +391,50 @@ def create_app() -> FastAPI:
# Keep connection alive – wait for client messages / ping-pong
while True:
try:
msg = await websocket.receive_text()
if msg == "ping":
raw = await websocket.receive_text()
# Legacy plain-text ping
if raw == "ping":
await websocket.send_text(json.dumps({"type": "pong"}))
continue
# JSON message dispatcher
try:
msg = json.loads(raw)
except Exception:
continue
msg_type = msg.get("type", "")
if msg_type == "ping":
await websocket.send_text(json.dumps({"type": "pong"}))
elif msg_type == "subscribe":
channel = msg.get("channel", "")
if channel:
ws_manager.subscribe(websocket, channel)
# Immediately send cached payload so client sees data without waiting
from .services.metrics_broadcaster import get_cached_metrics
_cached = get_cached_metrics(channel)
if _cached:
try:
await websocket.send_text(json.dumps(_cached, default=str))
except Exception:
pass
elif msg_type == "unsubscribe":
channel = msg.get("channel", "")
if channel:
ws_manager.unsubscribe(websocket, channel)
elif msg_type == "request":
action = msg.get("action", "")
request_id = msg.get("request_id", "")
if action in ("get_rrd", "get_node_rrd") and request_id:
from .services.metrics_broadcaster import handle_rrd_request
from .db import SessionLocal as _SL
asyncio.create_task(
handle_rrd_request(
websocket,
request_id,
action,
msg.get("params", {}),
_SL,
)
)
except WebSocketDisconnect:
break
except Exception:

View File

@@ -0,0 +1,410 @@
"""
Metrics Broadcaster — asyncio background tasks that push live metrics
to WebSocket channel subscribers.
Channels:
dashboard_metrics — all servers / VMs overview (10 s)
server_metrics:{server_id}_{node} — single Proxmox node status (5 s)
instance_metrics:{server_id}_{vmid}_{type}_{node} — VM / LXC status (5 s)
On-demand:
handle_rrd_request(ws, request_id, action, params, db_factory)
action = "get_rrd" → VM / container RRD data
action = "get_node_rrd" → node RRD data
NOTE: All Proxmox API calls are synchronous (proxmoxer / requests). They are
always executed in a thread-pool via asyncio.to_thread() so they never block
the event loop.
"""
import asyncio
import json
import time
from typing import Any, Dict, Optional, Tuple
from loguru import logger
# ──────────────────────────────────────────────────────────────────────────────
# In-memory caches
# ──────────────────────────────────────────────────────────────────────────────
# Last payload broadcast on each channel — sent immediately to new subscribers
_metrics_cache: Dict[str, Any] = {}
# RRD data cache: key = "{server_id}_{node}", value = (timestamp, rrd_data_list)
_rrd_cache: Dict[str, Tuple[float, list]] = {}
_RRD_TTL = 30.0 # seconds between actual RRD fetches from Proxmox
def get_cached_metrics(channel: str) -> Optional[Any]:
"""Return the last cached payload for a channel, or None."""
return _metrics_cache.get(channel)
# ──────────────────────────────────────────────────────────────────────────────
# Synchronous helpers (run inside a thread via asyncio.to_thread)
# ──────────────────────────────────────────────────────────────────────────────
def _get_ws_manager():
from ..websocket_manager import ws_manager
return ws_manager
def _get_all_servers(db_factory):
"""Return all ProxmoxServer rows (synchronous DB query)."""
from ..models import ProxmoxServer
db = db_factory()
try:
return db.query(ProxmoxServer).all()
finally:
db.close()
def _fetch_server_resources_with_nodes(server) -> dict:
"""
Pull VMs + containers + per-node CPU/RAM/Disk status + cached RRD slices.
Runs in a thread pool — safe to block.
"""
from ..proxmox_client import get_proxmox_resources
from ..api.proxmox._helpers import _get_proxmox_client
base: dict = {
"id": server.id,
"name": server.name,
"ip": server.ip_address,
"vms": [],
"containers": [],
"vms_count": 0,
"containers_count": 0,
"nodes": [],
}
# ── VMs + containers ──────────────────────────────────────────────────
try:
if server.use_password:
resources = get_proxmox_resources(
host=server.ip_address,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl,
)
else:
resources = get_proxmox_resources(
host=server.ip_address,
user=server.api_user,
token_name=server.api_token_name,
token_value=server.api_token_value,
verify_ssl=server.verify_ssl,
)
vms = resources.get("vms", [])
containers = resources.get("containers", [])
base["vms"] = vms
base["containers"] = containers
base["vms_count"] = len(vms)
base["containers_count"] = len(containers)
except Exception as exc:
base["error"] = str(exc)
return base
# ── Node status + RRD mini-slices ─────────────────────────────────────
try:
client = _get_proxmox_client(server)
if not client.is_connected():
return base
nodes_raw = client.get_nodes()
now = time.monotonic()
nodes_out = []
for n in nodes_raw:
node_name = n.get("node", "")
if not node_name:
continue
st = client.get_node_status(node_name) or {}
cpu_pct = round((st.get("cpu", 0) or 0) * 100, 1)
mem = st.get("memory") or {}
ram_pct = round((mem.get("used", 0) / mem.get("total", 1)) * 100, 1) if mem.get("total") else 0
rootfs = st.get("rootfs") or {}
disk_pct = round((rootfs.get("used", 0) / rootfs.get("total", 1)) * 100, 1) if rootfs.get("total") else 0
# RRD: fetch only when cache is stale (> _RRD_TTL seconds old)
cache_key = f"{server.id}_{node_name}"
if cache_key not in _rrd_cache or now - _rrd_cache[cache_key][0] >= _RRD_TTL:
try:
rrd_resp = client.get_node_rrddata(node_name, "hour")
rrd_data = rrd_resp.get("data", []) if isinstance(rrd_resp, dict) else (rrd_resp or [])
_rrd_cache[cache_key] = (now, rrd_data)
except Exception:
rrd_data = _rrd_cache.get(cache_key, (0, []))[1]
else:
rrd_data = _rrd_cache[cache_key][1]
# Build slim 24-point arrays for mini-charts
tail = rrd_data[-24:] if len(rrd_data) > 24 else rrd_data
rrd_cpu, rrd_ram, rrd_disk = [], [], []
for pt in tail:
rrd_cpu.append(round((pt.get("cpu", 0) or 0) * 100, 1))
mt = pt.get("memtotal") or 0
mu = pt.get("memused") or 0
rrd_ram.append(round((mu / mt * 100) if mt > 0 else 0, 1))
rt = pt.get("roottotal") or 0
ru = pt.get("rootused") or 0
rrd_disk.append(round((ru / rt * 100) if rt > 0 else 0, 1))
nodes_out.append({
"name": node_name,
"cpu_pct": cpu_pct,
"ram_pct": ram_pct,
"disk_pct": disk_pct,
"rrd_cpu": rrd_cpu,
"rrd_ram": rrd_ram,
"rrd_disk": rrd_disk,
})
base["nodes"] = nodes_out
except Exception as exc:
logger.debug(f"[metrics_broadcaster] node stats error for server {server.id}: {exc}")
return base
def _fetch_node_status(db_factory, server_id: int, node: str):
"""Return node status dict or None. Runs in a thread pool."""
from ..models import ProxmoxServer
from ..api.proxmox._helpers import _get_proxmox_client
db = db_factory()
try:
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
return None
client = _get_proxmox_client(server)
finally:
db.close()
if not client.is_connected():
return None
return client.get_node_status(node)
def _fetch_instance_status(db_factory, server_id: int, vmid: int, inst_type: str, node: str):
"""Return VM/LXC current status dict or None. Runs in a thread pool."""
from ..models import ProxmoxServer
from ..api.proxmox._helpers import _get_proxmox_client
db = db_factory()
try:
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
return None
client = _get_proxmox_client(server)
finally:
db.close()
if not client.is_connected():
return None
if inst_type == "qemu":
return client.get_vm_status(node, vmid)
return client.get_container_status(node, vmid)
def _fetch_rrd(db_factory, server_id: int, action: str, params: dict):
"""Fetch RRD time-series data. Runs in a thread pool."""
from ..models import ProxmoxServer
from ..api.proxmox._helpers import _get_proxmox_client
VALID_TIMEFRAMES = {"hour", "day", "week", "month", "year"}
node: str = params.get("node", "")
timeframe: str = params.get("timeframe", "hour")
if timeframe not in VALID_TIMEFRAMES:
timeframe = "hour"
db = db_factory()
try:
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise ValueError("Server not found")
client = _get_proxmox_client(server)
finally:
db.close()
if not client.is_connected():
raise ValueError("Cannot connect to Proxmox server")
if action == "get_node_rrd":
return client.get_node_rrddata(node, timeframe)
elif action == "get_rrd":
vmid = int(params.get("vmid", 0))
inst_type: str = params.get("inst_type", "qemu")
if inst_type == "qemu":
return client.get_vm_rrddata(node, vmid, timeframe)
return client.get_container_rrddata(node, vmid, timeframe)
raise ValueError(f"Unknown RRD action: {action}")
# ──────────────────────────────────────────────────────────────────────────────
# Dashboard metrics loop (push every 10 s)
# ──────────────────────────────────────────────────────────────────────────────
async def dashboard_metrics_loop(db_factory) -> None:
"""Push aggregated VM / container counts + node stats to 'dashboard_metrics' subscribers."""
wsman = _get_ws_manager()
while True:
await asyncio.sleep(2)
if not wsman.has_channel_subscribers("dashboard_metrics"):
continue
try:
servers = await asyncio.to_thread(_get_all_servers, db_factory)
results = await asyncio.gather(
*[asyncio.to_thread(_fetch_server_resources_with_nodes, srv) for srv in servers],
return_exceptions=True,
)
all_resources: dict = {"servers": [], "total_vms": 0, "total_containers": 0}
for r in results:
if isinstance(r, Exception):
continue
all_resources["servers"].append(r)
all_resources["total_vms"] += r.get("vms_count", 0)
all_resources["total_containers"] += r.get("containers_count", 0)
payload = {
"type": "metrics_update",
"channel": "dashboard_metrics",
"data": all_resources,
}
_metrics_cache["dashboard_metrics"] = payload
await wsman.broadcast_to_channel("dashboard_metrics", payload)
except asyncio.CancelledError:
break
except Exception as exc:
logger.warning(f"[metrics_broadcaster] dashboard loop error: {exc}")
# ──────────────────────────────────────────────────────────────────────────────
# Server / node metrics loop (push every 5 s)
# ──────────────────────────────────────────────────────────────────────────────
async def server_metrics_loop(db_factory) -> None:
"""Push node status to 'server_metrics:{server_id}_{node}' subscribers."""
wsman = _get_ws_manager()
while True:
await asyncio.sleep(2)
channels = wsman.get_channels_matching("server_metrics:")
if not channels:
continue
async def _push_node(channel: str) -> None:
try:
# channel format: server_metrics:{server_id}_{node}
suffix = channel.split(":", 1)[1]
sep = suffix.index("_")
server_id = int(suffix[:sep])
node = suffix[sep + 1:]
if not node:
return
status = await asyncio.to_thread(
_fetch_node_status, db_factory, server_id, node
)
if not status:
return
payload = {
"type": "metrics_update",
"channel": channel,
"data": {"status": status},
}
_metrics_cache[channel] = payload
await wsman.broadcast_to_channel(channel, payload)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.debug(f"[metrics_broadcaster] server metrics error on '{channel}': {exc}")
await asyncio.gather(*[_push_node(ch) for ch in channels], return_exceptions=True)
# ──────────────────────────────────────────────────────────────────────────────
# Instance metrics loop (push every 5 s)
# ──────────────────────────────────────────────────────────────────────────────
async def instance_metrics_loop(db_factory) -> None:
"""Push VM/LXC status to 'instance_metrics:{server_id}_{vmid}_{type}_{node}'."""
wsman = _get_ws_manager()
while True:
await asyncio.sleep(2)
channels = wsman.get_channels_matching("instance_metrics:")
if not channels:
continue
async def _push_instance(channel: str) -> None:
try:
# channel format: instance_metrics:{server_id}_{vmid}_{type}_{node}
suffix = channel.split(":", 1)[1]
parts = suffix.split("_", 3)
if len(parts) != 4:
return
server_id_str, vmid_str, inst_type, node = parts
if not node:
return
vmid = int(vmid_str)
status = await asyncio.to_thread(
_fetch_instance_status, db_factory, int(server_id_str), vmid, inst_type, node
)
if not status:
return
payload = {
"type": "metrics_update",
"channel": channel,
"data": status,
}
_metrics_cache[channel] = payload
await wsman.broadcast_to_channel(channel, payload)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.debug(f"[metrics_broadcaster] instance metrics error on '{channel}': {exc}")
await asyncio.gather(*[_push_instance(ch) for ch in channels], return_exceptions=True)
# ──────────────────────────────────────────────────────────────────────────────
# On-demand RRD request handler
# ──────────────────────────────────────────────────────────────────────────────
async def handle_rrd_request(
websocket,
request_id: str,
action: str,
params: dict,
db_factory,
) -> None:
"""
Handle a client-initiated RRD data request and reply directly to the
requesting WebSocket connection. The actual Proxmox call runs in a thread.
Expected params keys:
server_id — int
node — str
timeframe — "hour" | "day" | "week" | "month" | "year"
vmid — int (for get_rrd only)
inst_type — "qemu" | "lxc" (for get_rrd only)
"""
error_msg = None
data = None
try:
server_id = int(params.get("server_id", 0))
data = await asyncio.to_thread(_fetch_rrd, db_factory, server_id, action, params)
except Exception as exc:
logger.debug(f"[metrics_broadcaster] RRD request error ({action}): {exc}")
error_msg = str(exc)
try:
payload: dict = {"type": "rrd_response", "request_id": request_id, "data": data}
if error_msg:
payload["error"] = error_msg
await websocket.send_text(json.dumps(payload, default=str))
except Exception:
pass
# ──────────────────────────────────────────────────────────────────────────────
# Entry-point — call from lifespan
# ──────────────────────────────────────────────────────────────────────────────
def start_metrics_broadcaster(db_factory) -> None:
"""
Schedule all metrics broadcasting coroutines as asyncio Tasks.
Must be called from within a running asyncio event loop (e.g. lifespan).
"""
asyncio.create_task(dashboard_metrics_loop(db_factory), name="metrics:dashboard")
asyncio.create_task(server_metrics_loop(db_factory), name="metrics:servers")
asyncio.create_task(instance_metrics_loop(db_factory), name="metrics:instances")
logger.info("[metrics_broadcaster] Metrics broadcasting tasks started")

View File

@@ -286,7 +286,7 @@
</div>
<div class="modal-body p-0">
<div id="taskLogStatus" class="px-3 pt-3"></div>
<pre id="taskLogContent" style="max-height:400px;overflow-y:auto;background:var(--bs-dark,#1e1e2e);color:#cdd6f4;padding:1rem;margin:0;font-size:.82rem;"></pre>
<pre id="taskLogContent" style="max-height:400px;overflow-y:auto;background:var(--bg-surface-solid);color:var(--text-primary);padding:1rem;margin:0;font-size:.82rem;"></pre>
</div>
</div>
</div>

View File

@@ -169,8 +169,61 @@
// Also define as local function for scripts that don't use window prefix
var fetchWithAuth = window.fetchWithAuth;
// Global ESC handler for closing modals
// ==================== Auto token refresh ====================
// Decodes JWT payload (base64url) without verifying signature
function _jwtPayload(token) {
try {
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(atob(base64));
} catch (e) {
return null;
}
}
// Refresh the JWT token silently; returns true on success
async function _refreshToken() {
const token = localStorage.getItem('access_token');
if (!token) return false;
try {
const resp = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
if (resp.ok) {
const data = await resp.json();
localStorage.setItem('access_token', data.access_token);
_scheduleTokenRefresh(); // reschedule with new expiry
return true;
}
} catch (e) { /* network error */ }
return false;
}
// Schedule a proactive refresh 2 minutes before JWT expiry
function _scheduleTokenRefresh() {
if (window._tokenRefreshTimer) clearTimeout(window._tokenRefreshTimer);
const token = localStorage.getItem('access_token');
if (!token) return;
const payload = _jwtPayload(token);
if (!payload || !payload.exp) return;
const nowSec = Math.floor(Date.now() / 1000);
const msUntilRefresh = (payload.exp - nowSec - 120) * 1000; // 2 min before expiry
if (msUntilRefresh <= 0) {
// Token already expired or about to expire - try immediately
_refreshToken();
return;
}
window._tokenRefreshTimer = setTimeout(_refreshToken, msUntilRefresh);
}
// Kick off the scheduler on every page load (except login)
if (window.location.pathname !== '/login') {
_scheduleTokenRefresh();
}
// ==================== End auto token refresh ====================
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
// Find all visible modals and close the topmost one
@@ -1575,7 +1628,7 @@ body.update-in-progress .layout {
// Task Drawer — global WebSocket-based live task indicator
// ================================================================
let taskDrawerOpen = false;
let taskDrawerWS = null;
var taskDrawerWS = null; // var → exposes as window.taskDrawerWS for child templates
let taskDrawerTasks = {};
let taskDrawerReconnectTimer = null;
let taskDrawerReconnectDelay = 2000;
@@ -1679,6 +1732,16 @@ body.update-in-progress .layout {
}
updateTaskBadge();
if (taskDrawerOpen) renderTaskDrawer();
// Route to registered per-type handlers
if (_wsHandlers[msg.type]) { try { _wsHandlers[msg.type](msg); } catch(e) {} }
// Resolve pending request-response promises
if (msg.type === 'rrd_response' && msg.request_id && _wsPendingRequests[msg.request_id]) {
const { resolve, timer } = _wsPendingRequests[msg.request_id];
clearTimeout(timer);
delete _wsPendingRequests[msg.request_id];
if (msg.error) { /* still resolve — caller decides what to do */ }
resolve(msg);
}
} catch {}
};
taskDrawerWS.onclose = () => {
@@ -1690,8 +1753,95 @@ body.update-in-progress .layout {
setInterval(() => {
if (taskDrawerWS && taskDrawerWS.readyState === WebSocket.OPEN) taskDrawerWS.send(JSON.stringify({type:'ping'}));
}, 20000);
// Re-subscribe to all active channels after reconnect
_wsActiveSubscriptions.forEach(channel => {
taskDrawerWS.send(JSON.stringify({type: 'subscribe', channel}));
});
};
}
// ── WebSocket utility API (usable from any page script) ──────────────────
/** Registry of per-message-type handlers. */
const _wsHandlers = {};
/** Pending request-response promises keyed by request_id. */
const _wsPendingRequests = {};
/** Channels this page is currently subscribed to (for reconnect restore). */
const _wsActiveSubscriptions = new Set();
let _wsReqCounter = 0;
/**
* Register a handler function for a specific WebSocket message type.
* Multiple calls with the same type replace the previous handler.
*/
function registerWsHandler(type, fn) {
_wsHandlers[type] = fn;
}
/**
* Call fn immediately if the WebSocket is open, otherwise wait for onopen.
*/
function onWsReady(fn) {
if (taskDrawerWS && taskDrawerWS.readyState === WebSocket.OPEN) {
fn();
} else if (taskDrawerWS) {
const prev = taskDrawerWS.onopen;
taskDrawerWS.onopen = function(e) {
if (prev) prev.call(this, e);
fn();
};
}
}
/**
* Subscribe this tab to a named metrics channel.
* The subscription is remembered so it can be re-sent after reconnect.
*/
function subscribeWsChannel(channel) {
_wsActiveSubscriptions.add(channel);
if (taskDrawerWS && taskDrawerWS.readyState === WebSocket.OPEN) {
taskDrawerWS.send(JSON.stringify({type: 'subscribe', channel}));
}
}
/**
* Unsubscribe from a channel and remove it from the reconnect set.
*/
function unsubscribeWsChannel(channel) {
_wsActiveSubscriptions.delete(channel);
if (taskDrawerWS && taskDrawerWS.readyState === WebSocket.OPEN) {
taskDrawerWS.send(JSON.stringify({type: 'unsubscribe', channel}));
}
}
/**
* Send an on-demand request over WebSocket and return a Promise that
* resolves with the server's rrd_response message.
* Automatically falls back gracefully if WS is unavailable.
* @param {string} action — "get_rrd" or "get_node_rrd"
* @param {object} params — { server_id, node, timeframe, vmid?, inst_type? }
* @returns {Promise<object>} resolves with { data, error? }
*/
function sendWsRequest(action, params) {
return new Promise((resolve, reject) => {
if (!taskDrawerWS || taskDrawerWS.readyState !== WebSocket.OPEN) {
reject(new Error('WebSocket not open'));
return;
}
const request_id = `req_${++_wsReqCounter}_${Date.now()}`;
const timer = setTimeout(() => {
if (_wsPendingRequests[request_id]) {
delete _wsPendingRequests[request_id];
reject(new Error('WS request timed out'));
}
}, 12000);
_wsPendingRequests[request_id] = { resolve, timer };
taskDrawerWS.send(JSON.stringify({type: 'request', action, request_id, params}));
});
}
</script>
<!-- Bootstrap 5 JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>

View File

@@ -469,10 +469,10 @@ function escapeHtml(str) {
}
// Two-speed refresh:
// • refreshVMs() — every refresh_interval (fast, 1 request)
// • WS push (dashboard_metrics channel) — every 10 s from server
// • _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
const NODE_REFRESH_MIN_MS = 300000; // HTTP fallback every 5 min (WS provides node data normally)
async function refreshVMs() {
showRefreshSpin(true);
@@ -480,6 +480,17 @@ async function refreshVMs() {
const res = await fetchWithAuth('/proxmox/api/resources/all', { hideOnForbidden: true });
if (!res || !res.ok || res.forbidden) { showRefreshSpin(false); return; }
const data = await res.json();
renderDashboardData(data);
} catch (e) {
// silent
} finally {
showRefreshSpin(false);
}
}
function renderDashboardData(data) {
showRefreshSpin(true);
try {
let totalVms = 0, runningVms = 0, totalCt = 0, runningCt = 0;
const allVms = [];
@@ -532,15 +543,89 @@ async function refreshVMs() {
}
}
// 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();
// Use node data from WS payload (cpu_pct/ram_pct/disk_pct/rrd_*) if available
const allNodes = [];
for (const server of (data.servers || [])) {
if (Array.isArray(server.nodes)) {
for (const node of server.nodes) allNodes.push(node);
}
}
if (allNodes.length > 0) {
const avgCpu = Math.round(allNodes.reduce((s, n) => s + (n.cpu_pct || 0), 0) / allNodes.length);
const avgRam = Math.round(allNodes.reduce((s, n) => s + (n.ram_pct || 0), 0) / allNodes.length);
const avgDisk = Math.round(allNodes.reduce((s, n) => s + (n.disk_pct || 0), 0) / allNodes.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 from WS-provided RRD slices
const cpuHistoryAll = [], ramHistoryAll = [], diskHistoryAll = [];
for (const node of allNodes) {
if (Array.isArray(node.rrd_cpu) && node.rrd_cpu.length > 0) {
cpuHistoryAll.push(node.rrd_cpu);
ramHistoryAll.push(node.rrd_ram || []);
diskHistoryAll.push(node.rrd_disk || []);
}
}
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);
const ramSlice = avgH(ramHistoryAll);
const diskSlice = avgH(diskHistoryAll);
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 {
drawMiniBarChart('diskMiniChart', Array(24).fill(0).map((_, i) => (i / 23) * avgDisk * 0.9 + avgDisk * 0.1), 'rgba(16,185,129,0.7)');
}
});
}
// Nodes panel
const panel = document.getElementById('nodesPanel');
const onlineMeta = document.getElementById('nodesOnlineMeta');
if (onlineMeta) onlineMeta.textContent = allNodes.length + '/' + allNodes.length + ' ' + t('online').toLowerCase();
if (panel) {
panel.innerHTML = allNodes.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>
</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.cpu_pct}%"></div></div>
<span class="node-bar-pct">${node.cpu_pct}%</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.ram_pct}%"></div></div>
<span class="node-bar-pct">${node.ram_pct}%</span>
</div>
</div>
`).join('');
}
_lastNodeRefresh = Date.now();
} else {
// Fallback: HTTP poll if WS has no node data yet
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(); });
}
}
}
@@ -552,7 +637,7 @@ async function refreshVMs() {
}
}
// Init — reads refresh_interval from panel settings, falls back to 10s
// Init — loads initial data then subscribes to WebSocket push updates
document.addEventListener('DOMContentLoaded', async function() {
// Initial full load (VMs + nodes)
showRefreshSpin(true);
@@ -569,18 +654,21 @@ document.addEventListener('DOMContentLoaded', async function() {
} catch (e) {/* ignore */}
await refreshVMs();
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;
// Subscribe to WebSocket push updates.
// subscribeWsChannel queues the subscription and sends it on WS open/reconnect.
if (typeof subscribeWsChannel === 'function') {
subscribeWsChannel('dashboard_metrics');
}
if (typeof registerWsHandler === 'function') {
registerWsHandler('metrics_update', (msg) => {
if (msg.channel === 'dashboard_metrics') {
renderDashboardData(msg.data);
}
}
} catch (e) {/* use default */}
setInterval(refreshVMs, intervalMs);
});
}
window.addEventListener('beforeunload', () => {
if (typeof unsubscribeWsChannel === 'function') unsubscribeWsChannel('dashboard_metrics');
});
});
// ======== Alerts Modal ========

View File

@@ -807,15 +807,19 @@ function displayCommandOutput(result, command = null) {
async function loadRRDData(timeframe = 'hour') {
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/rrddata?node=${instanceNode}&timeframe=${timeframe}`);
if (!response.ok) return;
const rrdData = await response.json();
const data = Array.isArray(rrdData) ? rrdData : (rrdData.data || []);
if (data.length > 0) {
drawCharts(data, timeframe);
let rrdData = null;
// WS request-response
if (typeof sendWsRequest === 'function') {
try {
const resp = await sendWsRequest('get_rrd', {
server_id: serverId, vmid, inst_type: instanceType,
node: instanceNode, timeframe
});
if (resp && resp.data) rrdData = resp.data;
} catch { rrdData = null; }
}
const data = Array.isArray(rrdData) ? rrdData : (rrdData.data || []);
if (data.length > 0) drawCharts(data, timeframe);
} catch (error) {
console.error('Error loading RRD data:', error);
}
@@ -2159,13 +2163,95 @@ function initPage() {
loadInstanceData();
loadHAStatus();
// Автообновление каждые 2 секунды
// Auto-refresh RRD charts every 30 s via WS (Proxmox RRD updates ~60 s)
setInterval(() => {
if (document.getElementById('instance-content').style.display !== 'none') {
loadStatus();
const content = document.getElementById('instance-content');
if (content && content.style.display !== 'none') {
loadRRDData(currentTimeframe);
}
}, 2000);
}, 30000);
// Subscribe to WS push for live metrics; fall back to polling without WS.
const _instCh = `instance_metrics:${serverId}_${vmid}_${instanceType}_${instanceNode}`;
if (typeof registerWsHandler === 'function') {
registerWsHandler('metrics_update', (msg) => {
if (msg.channel !== _instCh) return;
const status = msg.data;
if (!status) return;
const el = document.getElementById('instance-content');
if (!el || el.style.display === 'none') return;
const cpuPercent = ((status.cpu || 0) * 100).toFixed(1);
const cpuEl = document.getElementById('metric-cpu');
const cpuBar = document.getElementById('metric-cpu-bar');
if (cpuEl) cpuEl.textContent = `${cpuPercent}%`;
if (cpuBar) {
cpuBar.style.width = `${cpuPercent}%`;
cpuBar.className = `progress-fill ${cpuPercent > 80 ? 'danger' : cpuPercent > 60 ? 'warning' : ''}`;
}
const memUsed = status.mem || 0;
const memMax = status.maxmem || 1;
const memPercent = ((memUsed / memMax) * 100).toFixed(1);
const memEl = document.getElementById('metric-memory');
const memDetail = document.getElementById('metric-memory-detail');
const memBar = document.getElementById('metric-memory-bar');
if (memEl) memEl.textContent = `${memPercent}%`;
if (memDetail) memDetail.textContent = `${formatBytes(memUsed)} / ${formatBytes(memMax)}`;
if (memBar) {
memBar.style.width = `${memPercent}%`;
memBar.className = `progress-fill ${memPercent > 80 ? 'danger' : memPercent > 60 ? 'warning' : ''}`;
}
const diskUsed = status.disk || 0;
const diskMax = status.maxdisk || 0;
const diskCard = document.getElementById('disk-metric-card');
if (diskMax > 0 && diskUsed > 0) {
const diskPercent = ((diskUsed / diskMax) * 100).toFixed(1);
const diskEl = document.getElementById('metric-disk');
const diskDetail = document.getElementById('metric-disk-detail');
const diskBar = document.getElementById('metric-disk-bar');
if (diskEl) diskEl.textContent = `${diskPercent}%`;
if (diskDetail) diskDetail.textContent = `${formatBytes(diskUsed)} / ${formatBytes(diskMax)}`;
if (diskBar) {
diskBar.style.width = `${diskPercent}%`;
diskBar.className = `progress-fill ${diskPercent > 80 ? 'danger' : diskPercent > 60 ? 'warning' : ''}`;
diskBar.style.display = '';
}
if (diskCard) diskCard.style.display = '';
} else if (diskCard && diskMax <= 0) {
diskCard.style.display = 'none';
}
const uptEl = document.getElementById('metric-uptime');
if (uptEl) uptEl.textContent = formatUptime(status.uptime);
const netinEl = document.getElementById('metric-netin');
const netoutEl = document.getElementById('metric-netout');
if (netinEl) netinEl.textContent = formatBytes(status.netin || 0);
if (netoutEl) netoutEl.textContent = formatBytes(status.netout || 0);
const drEl = document.getElementById('metric-diskread');
const dwEl = document.getElementById('metric-diskwrite');
if (drEl) drEl.textContent = formatBytes(status.diskread || 0);
if (dwEl) dwEl.textContent = formatBytes(status.diskwrite || 0);
instanceStatus = status.status;
updateStatus(status.status);
updateButtons(status.status);
});
}
// Subscribe to WS push — queued and sent when WS opens/reconnects
if (typeof subscribeWsChannel === 'function') {
subscribeWsChannel(_instCh);
}
window.addEventListener('beforeunload', () => {
if (typeof unsubscribeWsChannel === 'function') unsubscribeWsChannel(_instCh);
});
// Check if we should auto-open VNC console
const urlParams = new URLSearchParams(window.location.search);

View File

@@ -212,17 +212,16 @@ function showHostMonitorModal() {
<span id="updateIndicator" style="display: inline-block; margin-left: 0.5rem; width: 8px; height: 8px; background: var(--accent); border-radius: 50%; opacity: 0; transition: opacity 0.3s ease;"></span>
`;
document.getElementById('monitorContent').innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading_data')}...</div>`;
delete document.getElementById('monitorContent').dataset.inVmMode;
modal.style.display = 'flex';
loadHostMonitorData();
if (window.monitorInterval) clearInterval(window.monitorInterval);
window.monitorInterval = setInterval(() => {
if (modal.style.display === 'flex') {
loadHostMonitorData();
}
}, 5000);
// Subscribe to WS push for live node status
if (typeof subscribeWsChannel === 'function') {
subscribeWsChannel(`server_metrics:${serverId}_${hostNode}`);
}
}
async function loadHostMonitorData() {
@@ -233,30 +232,42 @@ async function loadHostMonitorData() {
const updateIndicator = document.getElementById('updateIndicator');
if (updateIndicator) updateIndicator.style.opacity = '1';
// Загружаем статус и RRD данные отдельно
const statusRes = await fetchWithAuth(`/proxmox/api/${serverId}/node/status?node=${hostNode}`);
if (!statusRes.ok) {
throw new Error(`HTTP error: ${statusRes.status}`);
// Use cached WS status if available, otherwise fall back to HTTP
let status = window._lastServerMetrics || null;
if (!status) {
const statusRes = await fetchWithAuth(`/proxmox/api/${serverId}/node/status?node=${hostNode}`);
if (!statusRes.ok) throw new Error(`HTTP error: ${statusRes.status}`);
status = await statusRes.json();
}
const status = await statusRes.json();
// Пытаемся загрузить RRD данные, но не падаем если не получилось
// RRD data via WS request
let rrdData = { data: [] };
try {
const rrdRes = await fetchWithAuth(`/proxmox/api/${serverId}/node/rrddata?node=${hostNode}&timeframe=hour`);
if (rrdRes.ok) {
rrdData = await rrdRes.json();
if (typeof sendWsRequest === 'function') {
const resp = await sendWsRequest('get_node_rrd', { server_id: serverId, node: hostNode, timeframe: 'hour' });
if (resp && resp.data) rrdData = resp.data;
}
} catch (rrdError) {
// RRD data error - charts will be unavailable
}
} catch { /* charts will be unavailable */ }
if (isFirstLoad) {
renderHostMonitorData(status, rrdData);
monitorContent.dataset.initialized = 'true';
// Auto-refresh RRD charts every 30 s while modal is open
if (window.monitorInterval) clearInterval(window.monitorInterval);
window.monitorInterval = setInterval(async () => {
const _modal = document.getElementById('monitorModal');
if (!_modal || _modal.style.display !== 'flex' || document.getElementById('monitorContent')?.dataset.inVmMode) {
clearInterval(window.monitorInterval);
window.monitorInterval = null;
return;
}
try {
if (typeof sendWsRequest === 'function') {
const resp = await sendWsRequest('get_node_rrd', { server_id: serverId, node: hostNode, timeframe: 'hour' });
if (resp && resp.data) updateHostCharts(resp.data);
}
} catch { /* silent */ }
}, 30000);
} else {
updateHostMonitorData(status, rrdData);
}
@@ -651,6 +662,12 @@ function updateHostCharts(rrdResponse) {
}
}
// Global: called from loadResources() when hostNode is first discovered
function _subscribeToServerMetrics() {
if (!hostNode || typeof subscribeWsChannel !== 'function') return;
subscribeWsChannel(`server_metrics:${serverId}_${hostNode}`);
}
async function loadResources() {
const container = document.getElementById('resources-container');
const loading = document.getElementById('loading');
@@ -677,6 +694,7 @@ async function loadResources() {
hostNode = vms[0]?.node || containers[0]?.node;
if (hostNode) {
loadHostMetrics();
_subscribeToServerMetrics();
}
}
@@ -891,13 +909,70 @@ window.addEventListener('DOMContentLoaded', () => {
}
loadResources();
// Автообновление метрик хоста каждые 5 секунд
setInterval(() => {
if (hostNode && document.getElementById('host-metrics').style.display !== 'none') {
loadHostMetrics();
if (typeof registerWsHandler === 'function') {
registerWsHandler('metrics_update', (msg) => {
if (!msg.channel) return;
// ── server / node metrics ────────────────────────────────────────
if (msg.channel.startsWith('server_metrics:')) {
const status = msg.data && msg.data.status;
if (!status) return;
// Cache for use by host monitor modal (avoids HTTP fetch)
window._lastServerMetrics = status;
// Update header host-metrics panel
const headerEl = document.getElementById('host-metrics');
if (headerEl && headerEl.style.display !== 'none') {
const cpuPercent = ((status.cpu || 0) * 100).toFixed(1);
const cpuEl = document.getElementById('host-cpu');
const cpuBar = document.getElementById('host-cpu-bar');
if (cpuEl) cpuEl.textContent = `${cpuPercent}%`;
if (cpuBar) {
cpuBar.style.width = `${cpuPercent}%`;
cpuBar.className = `progress-fill ${cpuPercent > 80 ? 'danger' : cpuPercent > 60 ? 'warning' : ''}`;
}
const memPercent = (((status.memory?.used || 0) / (status.memory?.total || 1)) * 100).toFixed(1);
const memEl = document.getElementById('host-memory');
const memBar = document.getElementById('host-memory-bar');
if (memEl) memEl.textContent = `${memPercent}%`;
if (memBar) {
memBar.style.width = `${memPercent}%`;
memBar.className = `progress-fill ${memPercent > 80 ? 'danger' : memPercent > 60 ? 'warning' : ''}`;
}
const uptime = status.uptime || 0;
const days = Math.floor(uptime / 86400);
const hours = Math.floor((uptime % 86400) / 3600);
const uptEl = document.getElementById('host-uptime');
if (uptEl) uptEl.textContent = days > 0 ? `${days}d ${hours}h` : `${hours}h`;
const loadavg = status.loadavg || [0, 0, 0];
const lv = typeof loadavg === 'string' ? parseFloat(loadavg) : (Array.isArray(loadavg) ? loadavg[0] : 0);
const laEl = document.getElementById('host-loadavg');
if (laEl) laEl.textContent = (Number(lv) || 0).toFixed(2);
}
// Update host monitor modal if open (not in VM mode)
const modal = document.getElementById('monitorModal');
if (modal && modal.style.display === 'flex' && !document.getElementById('monitorContent')?.dataset.inVmMode) {
updateHostMonitorData(status, null);
}
return;
}
// ── VM / LXC instance metrics ────────────────────────────────────
if (msg.channel.startsWith('instance_metrics:')) {
const modal = document.getElementById('monitorModal');
if (!modal || modal.style.display !== 'flex') return;
const mc = document.getElementById('monitorContent');
if (!mc || mc.dataset.vmChannel !== msg.channel) return;
updateMonitorData(msg.data, null);
}
});
}
window.addEventListener('beforeunload', () => {
if (hostNode && typeof unsubscribeWsChannel === 'function') {
unsubscribeWsChannel(`server_metrics:${serverId}_${hostNode}`);
}
}, 5000);
});
});
// === ФУНКЦИИ МОНИТОРИНГА ===
@@ -909,17 +984,17 @@ function showMonitorModal(serverId, node, vmid, type, name) {
<span id="updateIndicator" style="display: inline-block; margin-left: 0.5rem; width: 8px; height: 8px; background: var(--accent); border-radius: 50%; opacity: 0; transition: opacity 0.3s ease;"></span>
`;
document.getElementById('monitorContent').innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> Загрузка данных...</div>';
document.getElementById('monitorContent').dataset.inVmMode = '1';
document.getElementById('monitorContent').dataset.vmChannel = `instance_metrics:${serverId}_${vmid}_${type}_${node}`;
modal.style.display = 'flex';
loadMonitorData(serverId, node, vmid, type);
if (window.monitorInterval) clearInterval(window.monitorInterval);
window.monitorInterval = setInterval(() => {
if (modal.style.display === 'flex') {
loadMonitorData(serverId, node, vmid, type);
}
}, 5000);
// Subscribe to WS push for live instance status
if (typeof subscribeWsChannel === 'function') {
subscribeWsChannel(`instance_metrics:${serverId}_${vmid}_${type}_${node}`);
}
}
function closeMonitorModal() {
@@ -929,7 +1004,12 @@ function closeMonitorModal() {
modal.style.display = 'none';
if (monitorContent) {
// Unsubscribe from WS channels set during open
const vmCh = monitorContent.dataset.vmChannel;
if (vmCh && typeof unsubscribeWsChannel === 'function') unsubscribeWsChannel(vmCh);
delete monitorContent.dataset.initialized;
delete monitorContent.dataset.inVmMode;
delete monitorContent.dataset.vmChannel;
}
if (window.monitorInterval) {
@@ -949,17 +1029,20 @@ async function loadMonitorData(serverId, node, vmid, type) {
// Конвертировать тип: qemu -> vm, lxc -> container
const apiType = type === 'qemu' ? 'vm' : 'container';
const [statusRes, rrdRes] = await Promise.all([
fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/status?node=${node}`),
fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/rrddata?node=${node}&timeframe=hour`)
]);
if (!statusRes.ok || !rrdRes.ok) {
throw new Error(`HTTP error: ${statusRes.status} / ${rrdRes.status}`);
const statusRes = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/status?node=${node}`);
if (!statusRes.ok) {
throw new Error(`HTTP error: ${statusRes.status}`);
}
const status = await statusRes.json();
const rrdData = await rrdRes.json();
// RRD data via WS request
let rrdData = { data: [] };
try {
if (typeof sendWsRequest === 'function') {
const resp = await sendWsRequest('get_rrd', { server_id: serverId, node, vmid, inst_type: type, timeframe: 'hour' });
if (resp && resp.data) rrdData = resp.data;
}
} catch { /* charts unavailable */ }
if (isFirstLoad) {
renderMonitorData(status, rrdData, type);

View File

@@ -31,8 +31,6 @@
</div>
<div class="card">
{% if proxmox_servers %}
<div class="table-container">
<table class="data-table" id="serversTable">
<thead>
@@ -59,63 +57,14 @@
</tr>
</thead>
<tbody id="serversTableBody">
{% for server in proxmox_servers|sort(attribute='name') %}
<tr id="server-row-{{ server.id }}" data-server-id="{{ server.id }}" data-name="{{ server.name|lower }}" data-ip="{{ server.ip_address }}" data-node="{{ server.hostname or '' }}" data-vms="0" data-lxc="0" data-status="loading">
<td>
<div style="font-weight: 600; color: var(--text-primary);">{{ server.name }}</div>
{% if server.cluster_name %}
<span class="cluster-badge"><i class="fa-solid fa-network-wired"></i> {{ server.cluster_name }}</span>
{% endif %}
{% if server.description %}
<div style="font-size: 0.75rem; color: var(--text-muted);">{{ server.description }}</div>
{% endif %}
</td>
<td>
<div style="font-family: monospace; font-size: 0.875rem;">{{ server.ip_address }}:{{ server.port }}</div>
<div style="font-size: 0.75rem; color: var(--text-muted);"><i class="fa-solid fa-user"></i> {{ server.api_user }}</div>
</td>
<td>
{% if server.hostname and server.hostname != server.ip_address %}
<span style="font-size: 0.875rem; color: var(--info);">{{ server.hostname }}</span>
{% else %}
<span style="color: var(--text-muted);">—</span>
{% endif %}
</td>
<td id="server-vms-{{ server.id }}" style="text-align: center;">
<span style="color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i></span>
</td>
<td id="server-lxc-{{ server.id }}" style="text-align: center;">
<span style="color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i></span>
</td>
<td id="server-status-{{ server.id }}" style="text-align: center;">
<span class="status-badge status-loading"><i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}</span>
</td>
<td style="text-align: right;">
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button onclick="testConnection({{ server.id }})" class="btn btn-sm" style="background: var(--success); color: var(--text-on-success);" title="{{ t('test') }}">
<i class="fa-solid fa-plug"></i> {{ t('test') }}
</button>
<button onclick="window.location.href='/proxmox/server/{{ server.id }}'" class="btn btn-sm" style="background: var(--info); color: var(--text-on-info);" title="{{ t('details') }}">
<i class="fa-solid fa-chart-simple"></i> {{ t('details') }}
</button>
<button onclick="editServer({{ server.id }}, '{{ server.name }}')" class="btn btn-sm" style="background: var(--warning); color: var(--text-on-warning);" title="{{ t('edit') }}" data-permission="proxmox.servers.edit">
<i class="fa-solid fa-pen"></i>
</button>
<button onclick="deleteServer({{ server.id }}, '{{ server.name }}')" class="btn btn-sm btn-delete" title="{{ t('delete') }}" data-permission="proxmox.servers.delete">
<i class="fa-solid fa-trash"></i>
</button>
</div>
<tr id="servers-loading-row">
<td colspan="7" style="text-align:center; color:var(--text-muted); padding:2rem;">
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p style="color: var(--text-secondary);">
{{ t('no_proxmox_servers') }} <a href="#" onclick="showAddServerModal(); return false;" style="color: var(--success);">{{ t('add_first_server') }}</a>.
</p>
{% endif %}
</div>
<style>
@@ -1966,13 +1915,96 @@ async function controlResource(serverId, node, vmid, action, type) {
}
}
// -----------------------------------------------------------------------
// Dynamic server table loader (workspace-aware)
// -----------------------------------------------------------------------
async function loadServersTable() {
const tbody = document.getElementById('serversTableBody');
if (!tbody) return;
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:2rem;"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading') || 'Загрузка...'}</td></tr>`;
try {
const resp = await fetchWithAuth('/proxmox/api/servers');
if (!resp || !resp.ok) throw new Error('Failed to load servers');
const servers = await resp.json();
if (!servers || servers.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:2rem;">
${t('no_proxmox_servers') || 'Нет серверов.'} <a href="#" onclick="showAddServerModal();return false;" style="color:var(--success);">${t('add_first_server') || 'Добавить первый'}</a>
</td></tr>`;
return;
}
servers.sort((a, b) => a.name.localeCompare(b.name));
tbody.innerHTML = servers.map(server => `
<tr id="server-row-${server.id}" data-server-id="${server.id}" data-name="${(server.name || '').toLowerCase()}" data-ip="${server.ip_address || ''}" data-node="${server.hostname || ''}" data-vms="0" data-lxc="0" data-status="loading">
<td>
<div style="font-weight:600;color:var(--text-primary);">${escapeHtml(server.name)}</div>
${server.cluster_name ? `<span class="cluster-badge"><i class="fa-solid fa-network-wired"></i> ${escapeHtml(server.cluster_name)}</span>` : ''}
${server.description ? `<div style="font-size:0.75rem;color:var(--text-muted);">${escapeHtml(server.description)}</div>` : ''}
</td>
<td>
<div style="font-family:monospace;font-size:0.875rem;">${escapeHtml(server.ip_address)}:${server.port || 8006}</div>
<div style="font-size:0.75rem;color:var(--text-muted);"><i class="fa-solid fa-user"></i> ${escapeHtml(server.api_user || '')}</div>
</td>
<td>
${(server.hostname && server.hostname !== server.ip_address)
? `<span style="font-size:0.875rem;color:var(--info);">${escapeHtml(server.hostname)}</span>`
: `<span style="color:var(--text-muted);">—</span>`}
</td>
<td id="server-vms-${server.id}" style="text-align:center;">
<span style="color:var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i></span>
</td>
<td id="server-lxc-${server.id}" style="text-align:center;">
<span style="color:var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i></span>
</td>
<td id="server-status-${server.id}" style="text-align:center;">
<span class="status-badge status-loading"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading') || 'Загрузка'}</span>
</td>
<td style="text-align:right;">
<div style="display:flex;gap:0.5rem;justify-content:flex-end;">
<button onclick="testConnection(${server.id})" class="btn btn-sm" style="background:var(--success);color:var(--text-on-success);" title="${t('test') || 'Тест'}">
<i class="fa-solid fa-plug"></i> ${t('test') || 'Тест'}
</button>
<button onclick="window.location.href='/proxmox/server/${server.id}'" class="btn btn-sm" style="background:var(--info);color:var(--text-on-info);" title="${t('details') || 'Детали'}">
<i class="fa-solid fa-chart-simple"></i> ${t('details') || 'Детали'}
</button>
<button onclick="editServer(${server.id}, ${JSON.stringify(server.name)})" class="btn btn-sm" style="background:var(--warning);color:var(--text-on-warning);" title="${t('edit') || 'Изменить'}" data-permission="proxmox.servers.edit">
<i class="fa-solid fa-pen"></i>
</button>
<button onclick="deleteServer(${server.id}, ${JSON.stringify(server.name)})" class="btn btn-sm btn-delete" title="${t('delete') || 'Удалить'}" data-permission="proxmox.servers.delete">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</td>
</tr>
`).join('');
// Apply permission visibility after re-render
if (typeof applyPermissions === 'function') applyPermissions();
// Load statuses for the new rows
loadAllStatuses();
} catch (e) {
console.error('loadServersTable error:', e);
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--danger);padding:2rem;">${t('error') || 'Ошибка'}: ${e.message}</td></tr>`;
}
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Загрузить данные при открытии страницы
{% if proxmox_servers %}
// Wait for auth token to be validated
setTimeout(() => {
loadAllStatuses();
loadServersTable();
// Hook for workspace switch
window.reloadPageData = loadServersTable;
}, 500);
{% endif %}
</script>
<!-- Модальное окно мониторинга -->

View File

@@ -277,12 +277,6 @@
<h2><i class="fa-solid fa-gear"></i> {{ t('panel_settings') }}</h2>
<form id="panel-form" class="settings-form">
<div class="form-group">
<label for="refresh_interval">{{ t('refresh_interval_label') }}</label>
<input type="number" id="refresh_interval" min="1" max="60" value="5">
<small>{{ t('refresh_interval_help') }}</small>
</div>
<div class="form-group">
<label for="log_retention_days">{{ t('log_retention_label') }}</label>
<input type="number" id="log_retention_days" min="1" max="365" value="30">
@@ -1222,7 +1216,6 @@ document.addEventListener('DOMContentLoaded', function() {
const oldLang = panelSettings ? panelSettings.language : 'ru';
const data = {
refresh_interval: parseInt(document.getElementById('refresh_interval').value),
log_retention_days: parseInt(document.getElementById('log_retention_days').value),
language: currentLang
};
@@ -1398,7 +1391,6 @@ document.addEventListener('DOMContentLoaded', function() {
displayPanelInfo(settings);
// Fill form (only for admins)
document.getElementById('refresh_interval').value = settings.refresh_interval;
document.getElementById('log_retention_days').value = settings.log_retention_days;
document.getElementById('language').value = settings.language || 'ru';
}
@@ -1412,10 +1404,6 @@ document.addEventListener('DOMContentLoaded', function() {
}
panelInfo.innerHTML = `
<div class="info-item">
<label>${t('refresh_interval')}</label>
<div class="value">${settings.refresh_interval} ${t('sec')}</div>
</div>
<div class="info-item">
<label>${t('log_retention_days')}</label>
<div class="value">${settings.log_retention_days} ${t('days')}</div>

View File

@@ -80,6 +80,88 @@
</div>
<style>
/* ── Workspace Modal Overlay ────────────────────────────────── */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
z-index: 1050;
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.modal-container {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55);
animation: modalIn 0.2s ease;
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.96) translateY(-8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-header {
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
padding: 1.25rem 1.5rem !important;
border-bottom: 1px solid var(--border-color) !important;
background: transparent !important;
}
.modal-header h3 {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary);
}
.modal-body {
padding: 1.5rem !important;
background: transparent !important;
color: var(--text-primary) !important;
}
.modal-footer {
display: flex !important;
justify-content: flex-end !important;
gap: 0.75rem !important;
padding: 1rem 1.5rem !important;
border-top: 1px solid var(--border-color) !important;
background: transparent !important;
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-sm);
transition: color 0.15s, background 0.15s;
}
.modal-close:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
/* ── Workspace Grid ─────────────────────────────────────────── */
.ws-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));

View File

@@ -1,21 +1,24 @@
"""
WebSocket Connection Manager for real-time task updates.
Maintains per-user WebSocket connections and broadcasts task events.
WebSocket Connection Manager for real-time task updates and metrics push.
Maintains per-user WebSocket connections, broadcasts task events,
and supports channel-based subscriptions for metrics streaming.
"""
import asyncio
import json
from typing import Dict, Set
from typing import Dict, List, Set
from fastapi import WebSocket
from loguru import logger
class ConnectionManager:
"""Manages WebSocket connections grouped by user_id."""
"""Manages WebSocket connections grouped by user_id and by channel."""
def __init__(self):
# user_id -> set of active WebSocket connections
self._connections: Dict[int, Set[WebSocket]] = {}
# channel_name -> set of subscribed WebSocket connections
self._channel_subscribers: Dict[str, Set[WebSocket]] = {}
async def connect(self, websocket: WebSocket, user_id: int) -> None:
await websocket.accept()
@@ -29,8 +32,58 @@ class ConnectionManager:
self._connections[user_id].discard(websocket)
if not self._connections[user_id]:
del self._connections[user_id]
self.unsubscribe_all(websocket)
logger.debug(f"[WS TASKS] User {user_id} disconnected")
# ── Channel subscription helpers ──────────────────────────────────
def subscribe(self, websocket: WebSocket, channel: str) -> None:
"""Subscribe a WebSocket connection to a named channel."""
if channel not in self._channel_subscribers:
self._channel_subscribers[channel] = set()
self._channel_subscribers[channel].add(websocket)
logger.debug(f"[WS] Subscribed to channel '{channel}'")
def unsubscribe(self, websocket: WebSocket, channel: str) -> None:
"""Unsubscribe a WebSocket connection from a named channel."""
if channel in self._channel_subscribers:
self._channel_subscribers[channel].discard(websocket)
if not self._channel_subscribers[channel]:
del self._channel_subscribers[channel]
logger.debug(f"[WS] Unsubscribed from channel '{channel}'")
def unsubscribe_all(self, websocket: WebSocket) -> None:
"""Remove a WebSocket from every channel it is subscribed to."""
for channel in list(self._channel_subscribers):
self._channel_subscribers[channel].discard(websocket)
if not self._channel_subscribers[channel]:
del self._channel_subscribers[channel]
def has_channel_subscribers(self, channel: str) -> bool:
"""Return True if at least one connection is subscribed to *channel*."""
return bool(self._channel_subscribers.get(channel))
def get_channels_matching(self, prefix: str) -> List[str]:
"""Return all channel names that start with *prefix*."""
return [ch for ch in list(self._channel_subscribers) if ch.startswith(prefix)]
async def broadcast_to_channel(self, channel: str, data: dict) -> None:
"""Send JSON data to all connections subscribed to *channel*."""
connections = self._channel_subscribers.get(channel)
if not connections:
return
message = json.dumps(data, default=str)
dead: Set[WebSocket] = set()
for ws in list(connections):
try:
await ws.send_text(message)
except Exception:
dead.add(ws)
for ws in dead:
self._channel_subscribers[channel].discard(ws)
if not self._channel_subscribers.get(channel):
self._channel_subscribers.pop(channel, None)
@property
def active_user_count(self) -> int:
return len(self._connections)