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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
410
backend/app/services/metrics_broadcaster.py
Normal file
410
backend/app/services/metrics_broadcaster.py
Normal 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")
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ========
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// Загрузить данные при открытии страницы
|
||||
{% if proxmox_servers %}
|
||||
// Wait for auth token to be validated
|
||||
setTimeout(() => {
|
||||
loadAllStatuses();
|
||||
loadServersTable();
|
||||
// Hook for workspace switch
|
||||
window.reloadPageData = loadServersTable;
|
||||
}, 500);
|
||||
{% endif %}
|
||||
</script>
|
||||
|
||||
<!-- Модальное окно мониторинга -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user