Merge branch 'feature/metrics-history'
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -74,3 +74,6 @@ graphify-out/
|
|||||||
|
|
||||||
# Claude Code local preview config
|
# Claude Code local preview config
|
||||||
.claude/launch.json
|
.claude/launch.json
|
||||||
|
# Local-only Superpowers artifacts (specs, plans, SDD scratch) — never pushed
|
||||||
|
docs/superpowers/
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from ...ipam_service import IPAMService
|
|||||||
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
|
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
|
||||||
get_next_vmid, archive_and_delete_snapshots,
|
get_next_vmid, archive_and_delete_snapshots,
|
||||||
save_vm_instance, get_vm_instance, soft_delete_vm_instance)
|
save_vm_instance, get_vm_instance, soft_delete_vm_instance)
|
||||||
|
from ...services.metrics_history import query_instance_metrics, timeframe_to_range
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -1868,6 +1869,55 @@ def get_container_rrddata(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Metrics History (DB-only) ====================
|
||||||
|
|
||||||
|
def _resolve_metrics_window(timeframe, from_ts, to_ts):
|
||||||
|
from ...config import settings
|
||||||
|
now = int(time_lib.time())
|
||||||
|
if from_ts is not None and to_ts is not None:
|
||||||
|
f, t = int(from_ts), int(to_ts)
|
||||||
|
else:
|
||||||
|
f, t = timeframe_to_range(timeframe, now)
|
||||||
|
max_span = getattr(settings, "METRICS_RETENTION_DAYS", 30) * 86400
|
||||||
|
if t - f > max_span:
|
||||||
|
f = t - max_span
|
||||||
|
return f, t
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/{server_id}/vm/{vmid}/metrics")
|
||||||
|
def get_vm_metrics(
|
||||||
|
server_id: int,
|
||||||
|
vmid: int,
|
||||||
|
node: str = Query(None),
|
||||||
|
timeframe: str = Query("hour", regex="^(hour|day|week|month)$"),
|
||||||
|
from_ts: int = Query(None),
|
||||||
|
to_ts: int = Query(None),
|
||||||
|
nic: str = Query("all"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("vm:view")),
|
||||||
|
):
|
||||||
|
"""Retrieve VM metrics time-series from the local DB (no Proxmox call)."""
|
||||||
|
f, t = _resolve_metrics_window(timeframe, from_ts, to_ts)
|
||||||
|
return query_instance_metrics(db, server_id, vmid, f, t, nic)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/{server_id}/container/{vmid}/metrics")
|
||||||
|
def get_container_metrics(
|
||||||
|
server_id: int,
|
||||||
|
vmid: int,
|
||||||
|
node: str = Query(None),
|
||||||
|
timeframe: str = Query("hour", regex="^(hour|day|week|month)$"),
|
||||||
|
from_ts: int = Query(None),
|
||||||
|
to_ts: int = Query(None),
|
||||||
|
nic: str = Query("all"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("vm:view")),
|
||||||
|
):
|
||||||
|
"""Retrieve container metrics time-series from the local DB (no Proxmox call)."""
|
||||||
|
f, t = _resolve_metrics_window(timeframe, from_ts, to_ts)
|
||||||
|
return query_instance_metrics(db, server_id, vmid, f, t, nic)
|
||||||
|
|
||||||
|
|
||||||
# ==================== VNC Console ====================
|
# ==================== VNC Console ====================
|
||||||
|
|
||||||
@router.get("/api/{server_id}/vm/{vmid}/vnc")
|
@router.get("/api/{server_id}/vm/{vmid}/vnc")
|
||||||
@@ -2850,6 +2900,7 @@ def set_vm_owner(
|
|||||||
if not instance:
|
if not instance:
|
||||||
raise HTTPException(status_code=404, detail="VM not found in cache")
|
raise HTTPException(status_code=404, detail="VM not found in cache")
|
||||||
|
|
||||||
|
target_user = None
|
||||||
if body.user_id is not None:
|
if body.user_id is not None:
|
||||||
target_user = db.query(User).filter(User.id == body.user_id, User.is_active == True).first()
|
target_user = db.query(User).filter(User.id == body.user_id, User.is_active == True).first()
|
||||||
if not target_user:
|
if not target_user:
|
||||||
@@ -2858,6 +2909,29 @@ def set_vm_owner(
|
|||||||
instance.owner_id = body.user_id
|
instance.owner_id = body.user_id
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(f"Admin {current_user.username} set owner of VM {vmid} (server {server_id}) to user_id={body.user_id}")
|
logger.info(f"Admin {current_user.username} set owner of VM {vmid} (server {server_id}) to user_id={body.user_id}")
|
||||||
|
|
||||||
|
# Push a real-time event so every connected client refreshes the owner
|
||||||
|
# column without a manual page reload.
|
||||||
|
try:
|
||||||
|
from ...websocket_manager import broadcast_event
|
||||||
|
owner_user = (
|
||||||
|
{
|
||||||
|
"username": target_user.username,
|
||||||
|
"email": target_user.email,
|
||||||
|
"full_name": target_user.full_name,
|
||||||
|
}
|
||||||
|
if target_user else None
|
||||||
|
)
|
||||||
|
broadcast_event(
|
||||||
|
"vm_owner_changed",
|
||||||
|
server_id=server_id,
|
||||||
|
vmid=vmid,
|
||||||
|
owner_id=body.user_id,
|
||||||
|
owner_user=owner_user,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to broadcast vm_owner_changed: {e}")
|
||||||
|
|
||||||
return {"ok": True, "owner_id": body.user_id}
|
return {"ok": True, "owner_id": body.user_id}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class Settings(BaseSettings):
|
|||||||
# once your servers' host keys are in known_hosts.
|
# once your servers' host keys are in known_hosts.
|
||||||
SSH_REJECT_UNKNOWN_HOSTS: bool = Field(default=False, env="SSH_REJECT_UNKNOWN_HOSTS")
|
SSH_REJECT_UNKNOWN_HOSTS: bool = Field(default=False, env="SSH_REJECT_UNKNOWN_HOSTS")
|
||||||
|
|
||||||
|
# Metrics history
|
||||||
|
METRICS_COLLECT_INTERVAL: int = Field(default=15, env="METRICS_COLLECT_INTERVAL")
|
||||||
|
METRICS_RETENTION_DAYS: int = Field(default=30, env="METRICS_RETENTION_DAYS")
|
||||||
|
|
||||||
# CORS — comma-separated list of allowed origins; "*" is insecure in production
|
# CORS — comma-separated list of allowed origins; "*" is insecure in production
|
||||||
CORS_ORIGINS: str = Field(default="*", env="CORS_ORIGINS")
|
CORS_ORIGINS: str = Field(default="*", env="CORS_ORIGINS")
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from .auth import Role, user_servers, User, ActiveSession, LoginAttempt, Blocked
|
|||||||
from .proxmox import OSTemplateGroup, OSTemplate, ProxmoxServer, VMInstance, VMSnapshotArchive
|
from .proxmox import OSTemplateGroup, OSTemplate, ProxmoxServer, VMInstance, VMSnapshotArchive
|
||||||
from .ipam import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory
|
from .ipam import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory
|
||||||
from .tasks import TaskQueue, ProxmoxTask, DeployTask
|
from .tasks import TaskQueue, ProxmoxTask, DeployTask
|
||||||
|
from .metrics import InstanceMetric, InstanceNicMetric
|
||||||
from .settings import PanelSettings, SecuritySetting, Workspace, WorkspaceServer, WorkspaceUser
|
from .settings import PanelSettings, SecuritySetting, Workspace, WorkspaceServer, WorkspaceUser
|
||||||
from .misc import Notification, NotificationPreference, BackupJob, AuditLog
|
from .misc import Notification, NotificationPreference, BackupJob, AuditLog
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ __all__ = [
|
|||||||
'OSTemplateGroup', 'OSTemplate', 'ProxmoxServer', 'VMInstance', 'VMSnapshotArchive',
|
'OSTemplateGroup', 'OSTemplate', 'ProxmoxServer', 'VMInstance', 'VMSnapshotArchive',
|
||||||
'IPAMNetwork', 'IPAMPool', 'IPAMAllocation', 'IPAMHistory',
|
'IPAMNetwork', 'IPAMPool', 'IPAMAllocation', 'IPAMHistory',
|
||||||
'TaskQueue', 'ProxmoxTask', 'DeployTask',
|
'TaskQueue', 'ProxmoxTask', 'DeployTask',
|
||||||
|
'InstanceMetric', 'InstanceNicMetric',
|
||||||
'PanelSettings', 'SecuritySetting', 'Workspace', 'WorkspaceServer', 'WorkspaceUser',
|
'PanelSettings', 'SecuritySetting', 'Workspace', 'WorkspaceServer', 'WorkspaceUser',
|
||||||
'Notification', 'NotificationPreference', 'BackupJob', 'AuditLog'
|
'Notification', 'NotificationPreference', 'BackupJob', 'AuditLog'
|
||||||
]
|
]
|
||||||
|
|||||||
39
backend/app/models/metrics.py
Normal file
39
backend/app/models/metrics.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from sqlalchemy import Float
|
||||||
|
from .base import Base, Column, Integer, BigInteger, String, DateTime, Index, func
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceMetric(Base):
|
||||||
|
__tablename__ = "instance_metric"
|
||||||
|
|
||||||
|
id = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
|
||||||
|
server_id = Column(Integer, nullable=False)
|
||||||
|
vmid = Column(Integer, nullable=False)
|
||||||
|
vm_type = Column(String(10), nullable=True)
|
||||||
|
ts = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
cpu = Column(Float, nullable=True)
|
||||||
|
mem = Column(BigInteger, nullable=True)
|
||||||
|
maxmem = Column(BigInteger, nullable=True)
|
||||||
|
disk_used = Column(BigInteger, nullable=True)
|
||||||
|
disk_total = Column(BigInteger, nullable=True)
|
||||||
|
netin_rate = Column(Float, nullable=True)
|
||||||
|
netout_rate = Column(Float, nullable=True)
|
||||||
|
diskread_rate = Column(Float, nullable=True)
|
||||||
|
diskwrite_rate = Column(Float, nullable=True)
|
||||||
|
iops_read = Column(Float, nullable=True)
|
||||||
|
iops_write = Column(Float, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_instance_metric_lookup", "server_id", "vmid", "ts"),)
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceNicMetric(Base):
|
||||||
|
__tablename__ = "instance_nic_metric"
|
||||||
|
|
||||||
|
id = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
|
||||||
|
server_id = Column(Integer, nullable=False)
|
||||||
|
vmid = Column(Integer, nullable=False)
|
||||||
|
ts = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
dev = Column(String(64), nullable=False)
|
||||||
|
in_rate = Column(Float, nullable=True)
|
||||||
|
out_rate = Column(Float, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_instance_nic_metric_lookup", "server_id", "vmid", "ts"),)
|
||||||
@@ -1310,6 +1310,29 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
|||||||
logger.debug(f"Не удалось получить сетевые интерфейсы VM {vmid} (guest agent может быть не установлен): {e}")
|
logger.debug(f"Не удалось получить сетевые интерфейсы VM {vmid} (guest agent может быть не установлен): {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def get_vm_blockstats(self, node: str, vmid: int) -> str:
|
||||||
|
"""Raw HMP 'info blockstats' text via the QEMU monitor (VM only).
|
||||||
|
Returns '' if unavailable."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
res = self.proxmox.nodes(node).qemu(vmid).monitor.post(command="info blockstats")
|
||||||
|
return res if isinstance(res, str) else str(res or "")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"blockstats unavailable for VM {vmid} on {node}: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_node_netstat(self, node: str) -> list:
|
||||||
|
"""Per-tap/veth interface counters for the node ([] on failure)."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
res = self.proxmox.nodes(node).netstat.get()
|
||||||
|
return res if isinstance(res, list) else []
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"netstat unavailable for node {node}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
def get_container_interfaces(self, node: str, vmid: int) -> List[Dict]:
|
def get_container_interfaces(self, node: str, vmid: int) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Получить сетевые интерфейсы контейнера
|
Получить сетевые интерфейсы контейнера
|
||||||
|
|||||||
@@ -606,4 +606,8 @@ def start_metrics_broadcaster(db_factory) -> None:
|
|||||||
asyncio.create_task(server_metrics_loop(db_factory), name="metrics:servers")
|
asyncio.create_task(server_metrics_loop(db_factory), name="metrics:servers")
|
||||||
asyncio.create_task(instance_metrics_loop(db_factory), name="metrics:instances")
|
asyncio.create_task(instance_metrics_loop(db_factory), name="metrics:instances")
|
||||||
asyncio.create_task(instances_list_metrics_loop(db_factory), name="metrics:instances_list")
|
asyncio.create_task(instances_list_metrics_loop(db_factory), name="metrics:instances_list")
|
||||||
|
# Import inside function to avoid circular imports
|
||||||
|
from .metrics_collector import metrics_collector_loop, metrics_retention_loop
|
||||||
|
asyncio.create_task(metrics_collector_loop(db_factory), name="metrics:collector")
|
||||||
|
asyncio.create_task(metrics_retention_loop(db_factory), name="metrics:retention")
|
||||||
logger.info("[metrics_broadcaster] Metrics broadcasting tasks started")
|
logger.info("[metrics_broadcaster] Metrics broadcasting tasks started")
|
||||||
|
|||||||
148
backend/app/services/metrics_collector.py
Normal file
148
backend/app/services/metrics_collector.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""Background collector that persists instance telemetry to our own tables."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from .metrics_history import (
|
||||||
|
compute_rate, parse_blockstats, parse_netstat, aggregate_fsinfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _status(client, inst_type, node, vmid):
|
||||||
|
if inst_type == "qemu":
|
||||||
|
return client.get_vm_status(node, vmid)
|
||||||
|
return client.get_container_status(node, vmid)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_once(db, items, clients, prev, _now=None):
|
||||||
|
"""One collection tick. Mutates `prev` to carry counters between ticks."""
|
||||||
|
from ..models import InstanceMetric, InstanceNicMetric
|
||||||
|
now = _now if _now is not None else time.monotonic()
|
||||||
|
ts = datetime.now(tz=timezone.utc)
|
||||||
|
|
||||||
|
# netstat is one call per (server_id, node); cache per tick.
|
||||||
|
netstat_cache: dict[tuple, list] = {}
|
||||||
|
# Track which prev keys are touched this tick so stale entries can be evicted.
|
||||||
|
seen: set = set()
|
||||||
|
|
||||||
|
for it in items:
|
||||||
|
if it.get("status") != "running":
|
||||||
|
continue
|
||||||
|
sid, vmid = it["server_id"], it["vmid"]
|
||||||
|
inst_type, node = it.get("type"), it.get("node")
|
||||||
|
client = clients.get(sid)
|
||||||
|
if client is None or not node:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
status = _status(client, inst_type, node, vmid)
|
||||||
|
if not status:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# disk fill
|
||||||
|
if inst_type == "qemu":
|
||||||
|
used, total = aggregate_fsinfo(client.get_vm_fsinfo(node, vmid))
|
||||||
|
else:
|
||||||
|
used = int(status.get("disk", 0) or 0)
|
||||||
|
total = int(status.get("maxdisk", 0) or 0)
|
||||||
|
|
||||||
|
# cumulative counters → rates
|
||||||
|
key = f"{sid}:{vmid}"
|
||||||
|
cur = {
|
||||||
|
"netin": float(status.get("netin", 0) or 0),
|
||||||
|
"netout": float(status.get("netout", 0) or 0),
|
||||||
|
"diskread": float(status.get("diskread", 0) or 0),
|
||||||
|
"diskwrite": float(status.get("diskwrite", 0) or 0),
|
||||||
|
}
|
||||||
|
if inst_type == "qemu":
|
||||||
|
rd_ops, wr_ops = parse_blockstats(client.get_vm_blockstats(node, vmid))
|
||||||
|
cur["rd_ops"], cur["wr_ops"] = float(rd_ops), float(wr_ops)
|
||||||
|
|
||||||
|
pkey = prev.get(key)
|
||||||
|
dt = (now - pkey[0]) if pkey else 0
|
||||||
|
pc = pkey[1] if pkey else {}
|
||||||
|
|
||||||
|
def rate(name):
|
||||||
|
return compute_rate(pc.get(name), cur.get(name), dt)
|
||||||
|
|
||||||
|
db.add(InstanceMetric(
|
||||||
|
server_id=sid, vmid=vmid, vm_type=inst_type, ts=ts,
|
||||||
|
cpu=status.get("cpu"), mem=status.get("mem"), maxmem=status.get("maxmem"),
|
||||||
|
disk_used=used, disk_total=total,
|
||||||
|
netin_rate=rate("netin"), netout_rate=rate("netout"),
|
||||||
|
diskread_rate=rate("diskread"), diskwrite_rate=rate("diskwrite"),
|
||||||
|
iops_read=rate("rd_ops") if inst_type == "qemu" else None,
|
||||||
|
iops_write=rate("wr_ops") if inst_type == "qemu" else None,
|
||||||
|
))
|
||||||
|
prev[key] = (now, cur)
|
||||||
|
seen.add(key)
|
||||||
|
|
||||||
|
# per-NIC
|
||||||
|
ck = (sid, node)
|
||||||
|
if ck not in netstat_cache:
|
||||||
|
netstat_cache[ck] = client.get_node_netstat(node)
|
||||||
|
per_dev = parse_netstat(netstat_cache[ck], vmid)
|
||||||
|
for dev, (din, dout) in per_dev.items():
|
||||||
|
nkey = f"{sid}:{vmid}:{dev}"
|
||||||
|
npkey = prev.get(nkey)
|
||||||
|
ndt = (now - npkey[0]) if npkey else 0
|
||||||
|
npc = npkey[1] if npkey else {}
|
||||||
|
db.add(InstanceNicMetric(
|
||||||
|
server_id=sid, vmid=vmid, ts=ts, dev=dev,
|
||||||
|
in_rate=compute_rate(npc.get("in"), float(din), ndt),
|
||||||
|
out_rate=compute_rate(npc.get("out"), float(dout), ndt),
|
||||||
|
))
|
||||||
|
prev[nkey] = (now, {"in": float(din), "out": float(dout)})
|
||||||
|
seen.add(nkey)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[metrics_collector] {sid}:{vmid} error: {e}")
|
||||||
|
|
||||||
|
# Evict stale keys from prev (VMs/NICs not seen this tick).
|
||||||
|
for k in list(prev):
|
||||||
|
if k not in seen:
|
||||||
|
del prev[k]
|
||||||
|
|
||||||
|
|
||||||
|
async def metrics_collector_loop(db_factory) -> None:
|
||||||
|
from ..config import settings
|
||||||
|
from .metrics_broadcaster import _enumerate_instances_for_list
|
||||||
|
prev: dict = {}
|
||||||
|
while True:
|
||||||
|
start = time.monotonic()
|
||||||
|
try:
|
||||||
|
items, clients = await asyncio.to_thread(_enumerate_instances_for_list, db_factory)
|
||||||
|
db = db_factory()
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(collect_once, db, items, clients, prev)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[metrics_collector] tick error: {e}")
|
||||||
|
await asyncio.sleep(max(1.0, settings.METRICS_COLLECT_INTERVAL - (time.monotonic() - start)))
|
||||||
|
|
||||||
|
|
||||||
|
async def metrics_retention_loop(db_factory) -> None:
|
||||||
|
from ..config import settings
|
||||||
|
from ..models import InstanceMetric, InstanceNicMetric
|
||||||
|
from datetime import timedelta
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
cutoff = datetime.now(tz=timezone.utc) - timedelta(days=settings.METRICS_RETENTION_DAYS)
|
||||||
|
db = db_factory()
|
||||||
|
try:
|
||||||
|
db.query(InstanceMetric).filter(InstanceMetric.ts < cutoff).delete()
|
||||||
|
db.query(InstanceNicMetric).filter(InstanceNicMetric.ts < cutoff).delete()
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[metrics_collector] retention error: {e}")
|
||||||
|
await asyncio.sleep(3600)
|
||||||
163
backend/app/services/metrics_history.py
Normal file
163
backend/app/services/metrics_history.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"""Pure helpers + read query for instance metric history."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import timezone as _tz
|
||||||
|
|
||||||
|
_RD_OPS = re.compile(r"rd_operations=(\d+)")
|
||||||
|
_WR_OPS = re.compile(r"wr_operations=(\d+)")
|
||||||
|
|
||||||
|
|
||||||
|
def compute_rate(prev, cur, dt):
|
||||||
|
"""Per-second rate from cumulative counters. None if unknowable; 0 on reset."""
|
||||||
|
if prev is None or cur is None or not dt or dt <= 0:
|
||||||
|
return None
|
||||||
|
delta = float(cur) - float(prev)
|
||||||
|
if delta < 0:
|
||||||
|
return 0.0
|
||||||
|
return delta / dt
|
||||||
|
|
||||||
|
|
||||||
|
def parse_blockstats(text: str):
|
||||||
|
"""Sum rd_operations / wr_operations across all block devices in HMP output."""
|
||||||
|
rd = sum(int(m) for m in _RD_OPS.findall(text or ""))
|
||||||
|
wr = sum(int(m) for m in _WR_OPS.findall(text or ""))
|
||||||
|
return rd, wr
|
||||||
|
|
||||||
|
|
||||||
|
def parse_netstat(rows, vmid: int):
|
||||||
|
"""Map dev -> (in_bytes, out_bytes) for the given vmid from /nodes/{node}/netstat."""
|
||||||
|
out: dict[str, tuple[int, int]] = {}
|
||||||
|
for r in rows or []:
|
||||||
|
try:
|
||||||
|
if int(r.get("vmid")) != int(vmid):
|
||||||
|
continue
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
dev = r.get("dev")
|
||||||
|
if not dev:
|
||||||
|
continue
|
||||||
|
out[dev] = (int(float(r.get("in", 0) or 0)), int(float(r.get("out", 0) or 0)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_fsinfo(disks):
|
||||||
|
"""Sum used/total bytes across guest filesystems."""
|
||||||
|
used = sum(int(d.get("used", 0) or 0) for d in disks or [])
|
||||||
|
total = sum(int(d.get("total", 0) or 0) for d in disks or [])
|
||||||
|
return used, total
|
||||||
|
|
||||||
|
|
||||||
|
_TIMEFRAME_SECONDS = {
|
||||||
|
"hour": 3600,
|
||||||
|
"day": 86400,
|
||||||
|
"week": 7 * 86400,
|
||||||
|
"month": 30 * 86400,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def timeframe_to_range(timeframe, now_ts):
|
||||||
|
"""Convert timeframe string to (from_ts, to_ts) tuple."""
|
||||||
|
span = _TIMEFRAME_SECONDS.get(timeframe, 3600)
|
||||||
|
return now_ts - span, now_ts
|
||||||
|
|
||||||
|
|
||||||
|
def pick_bucket_seconds(from_ts, to_ts, target_points=150, floor=15):
|
||||||
|
"""Calculate bucket size in seconds for time-series aggregation."""
|
||||||
|
span = max(1, to_ts - from_ts)
|
||||||
|
ideal = span // max(1, target_points)
|
||||||
|
return max(floor, int(ideal))
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_series(rows, from_ts, bucket, fields):
|
||||||
|
"""Aggregate time-series data into buckets, averaging numeric fields."""
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
buckets: dict[int, dict] = {}
|
||||||
|
order: list[int] = []
|
||||||
|
for r in rows:
|
||||||
|
b = from_ts + ((int(r["time"]) - from_ts) // bucket) * bucket
|
||||||
|
if b not in buckets:
|
||||||
|
buckets[b] = {f: [] for f in fields}
|
||||||
|
order.append(b)
|
||||||
|
for f in fields:
|
||||||
|
v = r.get(f)
|
||||||
|
if v is not None:
|
||||||
|
buckets[b][f].append(float(v))
|
||||||
|
out = []
|
||||||
|
for b in order:
|
||||||
|
point = {"time": b}
|
||||||
|
for f in fields:
|
||||||
|
vals = buckets[b][f]
|
||||||
|
point[f] = (sum(vals) / len(vals)) if vals else None
|
||||||
|
out.append(point)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_AGG_FIELDS = ["cpu", "mem", "maxmem", "netin", "netout",
|
||||||
|
"diskread", "diskwrite", "diskpct", "iops_read", "iops_write"]
|
||||||
|
|
||||||
|
|
||||||
|
def _to_ts(dt):
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=_tz.utc)
|
||||||
|
return int(dt.timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def query_instance_metrics(db, server_id, vmid, from_ts, to_ts, nic="all"):
|
||||||
|
from ..models import InstanceMetric, InstanceNicMetric
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
start = datetime.fromtimestamp(from_ts, tz=_tz.utc)
|
||||||
|
end = datetime.fromtimestamp(to_ts, tz=_tz.utc)
|
||||||
|
|
||||||
|
base = (db.query(InstanceMetric)
|
||||||
|
.filter(InstanceMetric.server_id == server_id,
|
||||||
|
InstanceMetric.vmid == vmid,
|
||||||
|
InstanceMetric.ts >= start, InstanceMetric.ts <= end)
|
||||||
|
.order_by(InstanceMetric.ts.asc()).all())
|
||||||
|
|
||||||
|
# Distinct NICs seen in the window (for the selector).
|
||||||
|
if nic == "all":
|
||||||
|
# Only fetch distinct dev names — avoids loading all NIC rows.
|
||||||
|
dev_rows = (db.query(InstanceNicMetric.dev)
|
||||||
|
.filter(InstanceNicMetric.server_id == server_id,
|
||||||
|
InstanceNicMetric.vmid == vmid,
|
||||||
|
InstanceNicMetric.ts >= start, InstanceNicMetric.ts <= end)
|
||||||
|
.distinct().all())
|
||||||
|
nics = sorted({r.dev for r in dev_rows})
|
||||||
|
nic_by_ts: dict[int, tuple] = {}
|
||||||
|
else:
|
||||||
|
# Load full NIC rows for the requested dev to build per-ts override map.
|
||||||
|
nic_rows = (db.query(InstanceNicMetric)
|
||||||
|
.filter(InstanceNicMetric.server_id == server_id,
|
||||||
|
InstanceNicMetric.vmid == vmid,
|
||||||
|
InstanceNicMetric.ts >= start, InstanceNicMetric.ts <= end)
|
||||||
|
.order_by(InstanceNicMetric.ts.asc()).all())
|
||||||
|
nics = sorted({r.dev for r in nic_rows})
|
||||||
|
nic_by_ts = {}
|
||||||
|
for r in nic_rows:
|
||||||
|
if r.dev == nic:
|
||||||
|
nic_by_ts[_to_ts(r.ts)] = (r.in_rate, r.out_rate)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for m in base:
|
||||||
|
ts = _to_ts(m.ts)
|
||||||
|
netin, netout = m.netin_rate, m.netout_rate
|
||||||
|
if nic != "all":
|
||||||
|
ov = nic_by_ts.get(ts)
|
||||||
|
netin, netout = (ov if ov else (None, None))
|
||||||
|
diskpct = (m.disk_used / m.disk_total * 100.0) if (m.disk_total or 0) > 0 else None
|
||||||
|
rows.append({
|
||||||
|
"time": ts,
|
||||||
|
"cpu": (m.cpu * 100.0) if m.cpu is not None else None,
|
||||||
|
"mem": m.mem, "maxmem": m.maxmem,
|
||||||
|
"netin": netin, "netout": netout,
|
||||||
|
"diskread": m.diskread_rate, "diskwrite": m.diskwrite_rate,
|
||||||
|
"diskpct": diskpct,
|
||||||
|
"iops_read": m.iops_read, "iops_write": m.iops_write,
|
||||||
|
})
|
||||||
|
|
||||||
|
bucket = pick_bucket_seconds(from_ts, to_ts)
|
||||||
|
data = aggregate_series(rows, from_ts, bucket, _AGG_FIELDS)
|
||||||
|
return {"data": data, "from": from_ts, "to": to_ts, "meta": {"nics": nics}}
|
||||||
74
backend/tests/test_api_metrics.py
Normal file
74
backend/tests/test_api_metrics.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Tests for /metrics read endpoints (Task 6)."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_endpoint_returns_series(client, admin_headers, db_session):
|
||||||
|
"""Seeds InstanceMetric rows then reads them via the API.
|
||||||
|
The endpoint reads only the DB — no Proxmox call required."""
|
||||||
|
from app.models import InstanceMetric
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
for i, cpu in enumerate((0.1, 0.3)):
|
||||||
|
db_session.add(InstanceMetric(
|
||||||
|
server_id=1, vmid=114, vm_type="qemu",
|
||||||
|
ts=datetime.fromtimestamp(now - 10 + i, tz=timezone.utc),
|
||||||
|
cpu=cpu, mem=100, maxmem=200, disk_used=10, disk_total=40,
|
||||||
|
netin_rate=1.0, netout_rate=2.0, diskread_rate=0.0, diskwrite_rate=0.0,
|
||||||
|
iops_read=5.0, iops_write=1.0))
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
r = client.get(
|
||||||
|
"/proxmox/api/1/vm/114/metrics?timeframe=hour&node=prod",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert "data" in body and "meta" in body
|
||||||
|
assert body["data"], "expected at least one bucket"
|
||||||
|
assert "nics" in body["meta"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_metrics_endpoint_returns_series(client, admin_headers, db_session):
|
||||||
|
"""Same flow for container endpoint."""
|
||||||
|
from app.models import InstanceMetric
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
db_session.add(InstanceMetric(
|
||||||
|
server_id=1, vmid=200, vm_type="lxc",
|
||||||
|
ts=datetime.fromtimestamp(now - 5, tz=timezone.utc),
|
||||||
|
cpu=0.2, mem=512, maxmem=1024, disk_used=20, disk_total=80,
|
||||||
|
netin_rate=3.0, netout_rate=1.5, diskread_rate=0.0, diskwrite_rate=0.0,
|
||||||
|
iops_read=2.0, iops_write=0.5))
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
r = client.get(
|
||||||
|
"/proxmox/api/1/container/200/metrics?timeframe=hour&node=prod",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert "data" in body and "meta" in body
|
||||||
|
assert "nics" in body["meta"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_explicit_from_to(client, admin_headers, db_session):
|
||||||
|
"""Explicit from_ts/to_ts take precedence over timeframe."""
|
||||||
|
from app.models import InstanceMetric
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
db_session.add(InstanceMetric(
|
||||||
|
server_id=1, vmid=300, vm_type="qemu",
|
||||||
|
ts=datetime.fromtimestamp(now - 30, tz=timezone.utc),
|
||||||
|
cpu=0.5, mem=256, maxmem=512, disk_used=5, disk_total=20,
|
||||||
|
netin_rate=0.5, netout_rate=0.5, diskread_rate=0.0, diskwrite_rate=0.0,
|
||||||
|
iops_read=1.0, iops_write=1.0))
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
r = client.get(
|
||||||
|
f"/proxmox/api/1/vm/300/metrics?from_ts={now - 60}&to_ts={now}",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert "data" in body and "meta" in body
|
||||||
37
backend/tests/test_metrics_client.py
Normal file
37
backend/tests/test_metrics_client.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from unittest.mock import MagicMock
|
||||||
|
from app.proxmox.client import ProxmoxClient
|
||||||
|
|
||||||
|
|
||||||
|
def _client_with_mock(proxmox):
|
||||||
|
c = ProxmoxClient.__new__(ProxmoxClient)
|
||||||
|
c.proxmox = proxmox
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_vm_blockstats_returns_text():
|
||||||
|
px = MagicMock()
|
||||||
|
px.nodes.return_value.qemu.return_value.monitor.post.return_value = "drive-scsi0: rd_operations=5 wr_operations=2"
|
||||||
|
c = _client_with_mock(px)
|
||||||
|
assert "rd_operations=5" in c.get_vm_blockstats("prod", 114)
|
||||||
|
px.nodes.return_value.qemu.return_value.monitor.post.assert_called_once_with(command="info blockstats")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_vm_blockstats_swallows_errors():
|
||||||
|
px = MagicMock()
|
||||||
|
px.nodes.return_value.qemu.return_value.monitor.post.side_effect = RuntimeError("nope")
|
||||||
|
c = _client_with_mock(px)
|
||||||
|
assert c.get_vm_blockstats("prod", 114) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_node_netstat_returns_rows():
|
||||||
|
px = MagicMock()
|
||||||
|
px.nodes.return_value.netstat.get.return_value = [{"dev": "tap114i0", "vmid": "114", "in": "1", "out": "2"}]
|
||||||
|
c = _client_with_mock(px)
|
||||||
|
assert c.get_node_netstat("prod")[0]["dev"] == "tap114i0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_node_netstat_swallows_errors():
|
||||||
|
px = MagicMock()
|
||||||
|
px.nodes.return_value.netstat.get.side_effect = RuntimeError("nope")
|
||||||
|
c = _client_with_mock(px)
|
||||||
|
assert c.get_node_netstat("prod") == []
|
||||||
83
backend/tests/test_metrics_collector.py
Normal file
83
backend/tests/test_metrics_collector.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""Tests for metrics_collector.collect_once (unit-testable core)."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from app.services.metrics_collector import collect_once
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
c = MagicMock()
|
||||||
|
# status/current
|
||||||
|
c.get_vm_status.return_value = {
|
||||||
|
"cpu": 0.5, "mem": 100, "maxmem": 200,
|
||||||
|
"netin": 1000, "netout": 2000, "diskread": 0, "diskwrite": 500,
|
||||||
|
}
|
||||||
|
c.get_vm_fsinfo.return_value = [{"used": 10, "total": 40}]
|
||||||
|
c.get_vm_blockstats.return_value = "drive-scsi0: rd_operations=100 wr_operations=50"
|
||||||
|
c.get_node_netstat.return_value = [
|
||||||
|
{"dev": "tap114i0", "vmid": "114", "in": "1000", "out": "2000"}]
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_once_first_tick_no_rates(db_session):
|
||||||
|
from app.models import InstanceMetric
|
||||||
|
items = [{"server_id": 1, "vmid": 114, "type": "qemu", "node": "prod", "status": "running"}]
|
||||||
|
clients = {1: _client()}
|
||||||
|
prev = {}
|
||||||
|
collect_once(db_session, items, clients, prev, _now=100.0)
|
||||||
|
db_session.commit()
|
||||||
|
m = db_session.query(InstanceMetric).one()
|
||||||
|
assert m.cpu == 0.5 and m.disk_used == 10 and m.disk_total == 40
|
||||||
|
assert m.netin_rate is None # no previous counter yet
|
||||||
|
assert m.iops_read is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_once_second_tick_computes_rates(db_session):
|
||||||
|
from app.models import InstanceMetric, InstanceNicMetric
|
||||||
|
items = [{"server_id": 1, "vmid": 114, "type": "qemu", "node": "prod", "status": "running"}]
|
||||||
|
c = _client()
|
||||||
|
clients = {1: c}
|
||||||
|
prev = {}
|
||||||
|
collect_once(db_session, items, clients, prev, _now=100.0)
|
||||||
|
# advance counters by 10 over 2 seconds
|
||||||
|
c.get_vm_status.return_value = {"cpu": 0.5, "mem": 100, "maxmem": 200,
|
||||||
|
"netin": 1020, "netout": 2040, "diskread": 0, "diskwrite": 500}
|
||||||
|
c.get_vm_blockstats.return_value = "drive-scsi0: rd_operations=110 wr_operations=56"
|
||||||
|
c.get_node_netstat.return_value = [{"dev": "tap114i0", "vmid": "114", "in": "1020", "out": "2040"}]
|
||||||
|
collect_once(db_session, items, clients, prev, _now=102.0)
|
||||||
|
db_session.commit()
|
||||||
|
rows = db_session.query(InstanceMetric).order_by(InstanceMetric.id).all()
|
||||||
|
assert rows[1].netin_rate == 10.0 # (1020-1000)/2
|
||||||
|
assert rows[1].iops_read == 5.0 # (110-100)/2
|
||||||
|
nic = db_session.query(InstanceNicMetric).order_by(InstanceNicMetric.id).all()[-1]
|
||||||
|
assert nic.in_rate == 10.0 # (1020-1000)/2
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_once_evicts_stale_prev(db_session):
|
||||||
|
"""Keys for VMs absent from the current tick must be pruned from prev."""
|
||||||
|
def _client_for(vmid_str):
|
||||||
|
c = MagicMock()
|
||||||
|
c.get_vm_status.return_value = {
|
||||||
|
"cpu": 0.1, "mem": 50, "maxmem": 100,
|
||||||
|
"netin": 0, "netout": 0, "diskread": 0, "diskwrite": 0,
|
||||||
|
}
|
||||||
|
c.get_vm_fsinfo.return_value = [{"used": 5, "total": 20}]
|
||||||
|
c.get_vm_blockstats.return_value = ""
|
||||||
|
c.get_node_netstat.return_value = [
|
||||||
|
{"dev": f"tap{vmid_str}i0", "vmid": vmid_str, "in": "0", "out": "0"}
|
||||||
|
]
|
||||||
|
return c
|
||||||
|
|
||||||
|
prev = {}
|
||||||
|
# Tick 1: only vmid 114 running
|
||||||
|
items_114 = [{"server_id": 1, "vmid": 114, "type": "qemu", "node": "prod", "status": "running"}]
|
||||||
|
clients_114 = {1: _client_for("114")}
|
||||||
|
collect_once(db_session, items_114, clients_114, prev, _now=100.0)
|
||||||
|
# After tick 1, prev must contain 114 keys
|
||||||
|
assert any("114" in k for k in prev), "Expected 114 keys in prev after tick 1"
|
||||||
|
|
||||||
|
# Tick 2: only vmid 115 running (114 is gone)
|
||||||
|
items_115 = [{"server_id": 1, "vmid": 115, "type": "qemu", "node": "prod", "status": "running"}]
|
||||||
|
clients_115 = {1: _client_for("115")}
|
||||||
|
collect_once(db_session, items_115, clients_115, prev, _now=102.0)
|
||||||
|
# 114 keys must be evicted; 115 keys must be present
|
||||||
|
assert not any("114" in k for k in prev), "Expected 114 keys evicted from prev after tick 2"
|
||||||
|
assert any("115" in k for k in prev), "Expected 115 keys in prev after tick 2"
|
||||||
71
backend/tests/test_metrics_history.py
Normal file
71
backend/tests/test_metrics_history.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
from app.services import metrics_history as mh
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_rate_normal():
|
||||||
|
assert mh.compute_rate(100, 160, 2.0) == 30.0
|
||||||
|
|
||||||
|
def test_compute_rate_no_prev():
|
||||||
|
assert mh.compute_rate(None, 160, 2.0) is None
|
||||||
|
|
||||||
|
def test_compute_rate_counter_reset():
|
||||||
|
assert mh.compute_rate(500, 10, 2.0) == 0.0
|
||||||
|
|
||||||
|
def test_compute_rate_zero_dt():
|
||||||
|
assert mh.compute_rate(100, 200, 0) is None
|
||||||
|
|
||||||
|
def test_parse_blockstats_sums_devices():
|
||||||
|
text = (
|
||||||
|
"drive-scsi0: rd_bytes=10 wr_bytes=20 rd_operations=100 wr_operations=50 flush_operations=1\n"
|
||||||
|
"drive-scsi1: rd_bytes=5 wr_bytes=5 rd_operations=7 wr_operations=3 flush_operations=0\n"
|
||||||
|
)
|
||||||
|
assert mh.parse_blockstats(text) == (107, 53)
|
||||||
|
|
||||||
|
def test_parse_blockstats_empty():
|
||||||
|
assert mh.parse_blockstats("") == (0, 0)
|
||||||
|
|
||||||
|
def test_parse_netstat_filters_by_vmid():
|
||||||
|
rows = [
|
||||||
|
{"dev": "tap114i0", "vmid": "114", "in": "1000", "out": "2000"},
|
||||||
|
{"dev": "tap114i1", "vmid": "114", "in": "10", "out": "20"},
|
||||||
|
{"dev": "tap200i0", "vmid": "200", "in": "9", "out": "9"},
|
||||||
|
]
|
||||||
|
out = mh.parse_netstat(rows, 114)
|
||||||
|
assert out == {"tap114i0": (1000, 2000), "tap114i1": (10, 20)}
|
||||||
|
|
||||||
|
def test_aggregate_fsinfo_sums():
|
||||||
|
disks = [{"used": 10, "total": 40}, {"used": 5, "total": 20}]
|
||||||
|
assert mh.aggregate_fsinfo(disks) == (15, 60)
|
||||||
|
|
||||||
|
def test_aggregate_fsinfo_empty():
|
||||||
|
assert mh.aggregate_fsinfo([]) == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeframe_to_range_hour():
|
||||||
|
assert mh.timeframe_to_range("hour", 10_000) == (10_000 - 3600, 10_000)
|
||||||
|
|
||||||
|
def test_timeframe_to_range_month():
|
||||||
|
assert mh.timeframe_to_range("month", 10_000_000) == (10_000_000 - 30 * 86400, 10_000_000)
|
||||||
|
|
||||||
|
def test_timeframe_to_range_unknown_defaults_hour():
|
||||||
|
assert mh.timeframe_to_range("nonsense", 10_000) == (10_000 - 3600, 10_000)
|
||||||
|
|
||||||
|
def test_pick_bucket_respects_floor():
|
||||||
|
# 1h window, target 150 -> 24s ideal, but floor 15 keeps >=15
|
||||||
|
assert mh.pick_bucket_seconds(0, 3600, target_points=150, floor=15) == 24
|
||||||
|
|
||||||
|
def test_pick_bucket_floor_applied():
|
||||||
|
assert mh.pick_bucket_seconds(0, 300, target_points=150, floor=15) == 15
|
||||||
|
|
||||||
|
def test_aggregate_series_averages_buckets():
|
||||||
|
rows = [
|
||||||
|
{"time": 0, "cpu": 10.0, "mem": 100},
|
||||||
|
{"time": 5, "cpu": 20.0, "mem": 200},
|
||||||
|
{"time": 20, "cpu": 40.0, "mem": None},
|
||||||
|
]
|
||||||
|
out = mh.aggregate_series(rows, from_ts=0, bucket=10, fields=["cpu", "mem"])
|
||||||
|
# bucket 0: times 0,5 -> cpu 15, mem 150 ; bucket 20: time 20 -> cpu 40, mem None
|
||||||
|
assert out[0] == {"time": 0, "cpu": 15.0, "mem": 150.0}
|
||||||
|
assert out[-1] == {"time": 20, "cpu": 40.0, "mem": None}
|
||||||
|
|
||||||
|
def test_aggregate_series_empty():
|
||||||
|
assert mh.aggregate_series([], 0, 10, ["cpu"]) == []
|
||||||
20
backend/tests/test_metrics_models.py
Normal file
20
backend/tests/test_metrics_models.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
def test_instance_metric_roundtrip(db_session):
|
||||||
|
from app.models import InstanceMetric, InstanceNicMetric
|
||||||
|
ts = datetime(2026, 6, 21, 12, 0, tzinfo=timezone.utc)
|
||||||
|
db_session.add(InstanceMetric(
|
||||||
|
server_id=1, vmid=114, vm_type="qemu", ts=ts,
|
||||||
|
cpu=0.25, mem=100, maxmem=200, disk_used=10, disk_total=40,
|
||||||
|
netin_rate=1.0, netout_rate=2.0, diskread_rate=0.0, diskwrite_rate=3.0,
|
||||||
|
iops_read=12.0, iops_write=4.0,
|
||||||
|
))
|
||||||
|
db_session.add(InstanceNicMetric(
|
||||||
|
server_id=1, vmid=114, ts=ts, dev="tap114i0", in_rate=5.0, out_rate=6.0))
|
||||||
|
db_session.commit()
|
||||||
|
m = db_session.query(InstanceMetric).one()
|
||||||
|
assert m.vmid == 114
|
||||||
|
assert m.iops_read == 12.0
|
||||||
|
n = db_session.query(InstanceNicMetric).one()
|
||||||
|
assert n.dev == "tap114i0"
|
||||||
|
assert n.out_rate == 6.0
|
||||||
45
backend/tests/test_metrics_query.py
Normal file
45
backend/tests/test_metrics_query.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# backend/tests/test_metrics_query.py
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from app.services.metrics_history import query_instance_metrics
|
||||||
|
|
||||||
|
|
||||||
|
def _row(db, InstanceMetric, ts, **kw):
|
||||||
|
db.add(InstanceMetric(server_id=1, vmid=114, vm_type="qemu",
|
||||||
|
ts=datetime.fromtimestamp(ts, tz=timezone.utc), **kw))
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_aggregate_all_nic(db_session):
|
||||||
|
from app.models import InstanceMetric
|
||||||
|
_row(db_session, InstanceMetric, 1000, cpu=0.10, mem=100, maxmem=200,
|
||||||
|
disk_used=10, disk_total=40, netin_rate=1.0, netout_rate=2.0,
|
||||||
|
diskread_rate=0.0, diskwrite_rate=0.0, iops_read=5.0, iops_write=1.0)
|
||||||
|
_row(db_session, InstanceMetric, 1005, cpu=0.30, mem=140, maxmem=200,
|
||||||
|
disk_used=20, disk_total=40, netin_rate=3.0, netout_rate=4.0,
|
||||||
|
diskread_rate=0.0, diskwrite_rate=0.0, iops_read=7.0, iops_write=3.0)
|
||||||
|
db_session.commit()
|
||||||
|
out = query_instance_metrics(db_session, 1, 114, from_ts=1000, to_ts=1010, nic="all")
|
||||||
|
assert out["from"] == 1000 and out["to"] == 1010
|
||||||
|
pt = out["data"][0]
|
||||||
|
assert pt["cpu"] == 20.0 # avg(10%,30%)
|
||||||
|
assert pt["netin"] == 2.0 # avg(1,3)
|
||||||
|
assert pt["diskpct"] == 37.5 # avg(25%,50%)
|
||||||
|
assert pt["iops_read"] == 6.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_specific_nic_overrides_net(db_session):
|
||||||
|
from app.models import InstanceMetric, InstanceNicMetric
|
||||||
|
_row(db_session, InstanceMetric, 1000, cpu=0.0, mem=0, maxmem=1,
|
||||||
|
disk_used=0, disk_total=1, netin_rate=99.0, netout_rate=99.0,
|
||||||
|
diskread_rate=0.0, diskwrite_rate=0.0, iops_read=0.0, iops_write=0.0)
|
||||||
|
db_session.add(InstanceNicMetric(server_id=1, vmid=114,
|
||||||
|
ts=datetime.fromtimestamp(1000, tz=timezone.utc), dev="tap114i0",
|
||||||
|
in_rate=7.0, out_rate=8.0))
|
||||||
|
db_session.commit()
|
||||||
|
out = query_instance_metrics(db_session, 1, 114, 1000, 1010, nic="tap114i0")
|
||||||
|
assert out["meta"]["nics"] == ["tap114i0"]
|
||||||
|
assert out["data"][0]["netin"] == 7.0 and out["data"][0]["netout"] == 8.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_empty(db_session):
|
||||||
|
out = query_instance_metrics(db_session, 1, 999, 0, 10, nic="all")
|
||||||
|
assert out["data"] == [] and out["meta"]["nics"] == []
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { useVMRrddata } from '@/hooks/use-instances';
|
import { useVMMetrics } from '@/hooks/use-instances';
|
||||||
import { formatBytes } from '@/lib/format';
|
import { formatBytes } from '@/lib/format';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -22,17 +22,31 @@ interface Props {
|
|||||||
node: string;
|
node: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIMEFRAMES = [
|
const PRESETS = [
|
||||||
{ value: 'hour', label: '1h' },
|
{ tf: 'hour', label: '1h' },
|
||||||
{ value: 'day', label: '24h' },
|
{ tf: 'day', label: '24h' },
|
||||||
{ value: 'week', label: '7d' },
|
{ tf: 'week', label: '7d' },
|
||||||
{ value: 'month', label: '30d' },
|
{ tf: 'month', label: '30d' },
|
||||||
{ value: 'year', label: '1y' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function formatTime(ts: number): string {
|
const MAX_SPAN = 30 * 86400;
|
||||||
|
|
||||||
|
function fmtTime(ts: number): string {
|
||||||
const d = new Date(ts * 1000);
|
const d = new Date(ts * 1000);
|
||||||
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a datetime-local string to unix seconds
|
||||||
|
function localToUnix(val: string): number | undefined {
|
||||||
|
if (!val) return undefined;
|
||||||
|
return Math.floor(new Date(val).getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert unix seconds to datetime-local value string
|
||||||
|
function unixToLocal(ts: number): string {
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChartCardProps {
|
interface ChartCardProps {
|
||||||
@@ -42,13 +56,17 @@ interface ChartCardProps {
|
|||||||
color: string;
|
color: string;
|
||||||
formatValue?: (v: number) => string;
|
formatValue?: (v: number) => string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
|
headerRight?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChartCard({ title, data, dataKey, color, formatValue, unit }: ChartCardProps) {
|
function ChartCard({ title, data, dataKey, color, formatValue, unit, headerRight }: ChartCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||||
|
{headerRight}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
@@ -62,7 +80,7 @@ function ChartCard({ title, data, dataKey, color, formatValue, unit }: ChartCard
|
|||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="time"
|
dataKey="time"
|
||||||
tickFormatter={formatTime}
|
tickFormatter={fmtTime}
|
||||||
className="text-[10px] fill-muted-foreground"
|
className="text-[10px] fill-muted-foreground"
|
||||||
tick={{ fontSize: 10 }}
|
tick={{ fontSize: 10 }}
|
||||||
/>
|
/>
|
||||||
@@ -70,17 +88,22 @@ function ChartCard({ title, data, dataKey, color, formatValue, unit }: ChartCard
|
|||||||
className="text-[10px] fill-muted-foreground"
|
className="text-[10px] fill-muted-foreground"
|
||||||
tick={{ fontSize: 10 }}
|
tick={{ fontSize: 10 }}
|
||||||
tickFormatter={(v) => (formatValue ? formatValue(v) : String(v))}
|
tickFormatter={(v) => (formatValue ? formatValue(v) : String(v))}
|
||||||
width={50}
|
width={55}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: 'hsl(var(--popover))',
|
backgroundColor: 'rgba(0, 0, 0, 0.65)',
|
||||||
border: '1px solid hsl(var(--border))',
|
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
color: '#fff',
|
||||||
}}
|
}}
|
||||||
formatter={(val: any) => [
|
labelStyle={{ color: '#fff' }}
|
||||||
val != null && typeof val === 'number' && formatValue ? formatValue(val) : `${val ?? ''}${unit || ''}`,
|
itemStyle={{ color: '#fff' }}
|
||||||
|
formatter={(val: unknown) => [
|
||||||
|
val != null && typeof val === 'number' && formatValue
|
||||||
|
? formatValue(val)
|
||||||
|
: `${val ?? ''}${unit || ''}`,
|
||||||
title,
|
title,
|
||||||
]}
|
]}
|
||||||
labelFormatter={(ts) => new Date((ts as number) * 1000).toLocaleString()}
|
labelFormatter={(ts) => new Date((ts as number) * 1000).toLocaleString()}
|
||||||
@@ -91,6 +114,7 @@ function ChartCard({ title, data, dataKey, color, formatValue, unit }: ChartCard
|
|||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
fill={`url(#grad-${dataKey})`}
|
fill={`url(#grad-${dataKey})`}
|
||||||
|
dot={false}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -102,45 +126,115 @@ function ChartCard({ title, data, dataKey, color, formatValue, unit }: ChartCard
|
|||||||
export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [timeframe, setTimeframe] = useState('hour');
|
const [timeframe, setTimeframe] = useState('hour');
|
||||||
const { data: rrdData, isLoading } = useVMRrddata(serverId, vmid, type, node, timeframe);
|
// custom range: raw datetime-local strings
|
||||||
|
const [fromLocal, setFromLocal] = useState('');
|
||||||
|
const [toLocal, setToLocal] = useState('');
|
||||||
|
const [nic, setNic] = useState('all');
|
||||||
|
|
||||||
// Parse RRD data into chart-friendly format
|
// Compute unix-second from/to with 30-day clamp
|
||||||
const chartData = (rrdData as Array<Record<string, unknown>> | undefined)?.map((point) => ({
|
const customRange = useMemo<{ from?: number; to?: number }>(() => {
|
||||||
time: Number(point.time),
|
const f = localToUnix(fromLocal);
|
||||||
cpu: (Number(point.cpu) || 0) * 100,
|
const to = localToUnix(toLocal);
|
||||||
mem: Number(point.mem) || Number(point.memused) || 0,
|
if (f == null || to == null) return {};
|
||||||
maxmem: Number(point.maxmem) || 0,
|
let from = f;
|
||||||
netin: Number(point.netin) || 0,
|
if (to - from > MAX_SPAN) from = to - MAX_SPAN;
|
||||||
netout: Number(point.netout) || 0,
|
return { from, to };
|
||||||
diskread: Number(point.diskread) || 0,
|
}, [fromLocal, toLocal]);
|
||||||
diskwrite: Number(point.diskwrite) || 0,
|
|
||||||
})) || [];
|
const hasCustom = customRange.from != null && customRange.to != null;
|
||||||
|
|
||||||
|
const { data, isLoading } = useVMMetrics(serverId, vmid, type, node, {
|
||||||
|
timeframe: hasCustom ? undefined : timeframe,
|
||||||
|
from: customRange.from,
|
||||||
|
to: customRange.to,
|
||||||
|
nic,
|
||||||
|
});
|
||||||
|
|
||||||
|
const points = (data?.data ?? []) as Array<Record<string, unknown>>;
|
||||||
|
const nics = data?.meta?.nics ?? [];
|
||||||
|
|
||||||
|
function resetCustom() {
|
||||||
|
setFromLocal('');
|
||||||
|
setToLocal('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the clamped from differs from the input, reflect it back
|
||||||
|
const displayFrom =
|
||||||
|
hasCustom && customRange.from != null ? unixToLocal(customRange.from) : fromLocal;
|
||||||
|
|
||||||
|
const nicSelector = (
|
||||||
|
<select
|
||||||
|
value={nic}
|
||||||
|
onChange={(e) => setNic(e.target.value)}
|
||||||
|
className="ml-1 rounded border border-border bg-background px-1 py-0.5 text-xs text-foreground focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">Все</option>
|
||||||
|
{nics.map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Timeframe selector */}
|
{/* Time controls */}
|
||||||
<div className="flex gap-1">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{TIMEFRAMES.map((tf) => (
|
{/* Preset buttons */}
|
||||||
<Button
|
<div className="flex gap-1">
|
||||||
key={tf.value}
|
{PRESETS.map((p) => (
|
||||||
variant={timeframe === tf.value ? 'default' : 'outline'}
|
<Button
|
||||||
size="sm"
|
key={p.tf}
|
||||||
onClick={() => setTimeframe(tf.value)}
|
variant={!hasCustom && timeframe === p.tf ? 'default' : 'outline'}
|
||||||
>
|
size="sm"
|
||||||
{tf.label}
|
onClick={() => {
|
||||||
</Button>
|
setTimeframe(p.tf);
|
||||||
))}
|
resetCustom();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom range inputs */}
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={displayFrom}
|
||||||
|
onChange={(e) => setFromLocal(e.target.value)}
|
||||||
|
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:outline-none"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={toLocal}
|
||||||
|
onChange={(e) => setToLocal(e.target.value)}
|
||||||
|
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:outline-none"
|
||||||
|
/>
|
||||||
|
{hasCustom && (
|
||||||
|
<Button variant="outline" size="sm" onClick={resetCustom}>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
) : points.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-20 text-sm text-muted-foreground">
|
||||||
|
Нет данных
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title={t('graphs.cpu_usage')}
|
title={t('graphs.cpu_usage')}
|
||||||
data={chartData}
|
data={points}
|
||||||
dataKey="cpu"
|
dataKey="cpu"
|
||||||
color="hsl(217, 91%, 60%)"
|
color="hsl(217, 91%, 60%)"
|
||||||
formatValue={(v) => `${v.toFixed(1)}%`}
|
formatValue={(v) => `${v.toFixed(1)}%`}
|
||||||
@@ -148,38 +242,64 @@ export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
|||||||
/>
|
/>
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title={t('graphs.memory_usage')}
|
title={t('graphs.memory_usage')}
|
||||||
data={chartData}
|
data={points}
|
||||||
dataKey="mem"
|
dataKey="mem"
|
||||||
color="hsl(142, 76%, 36%)"
|
color="hsl(142, 76%, 36%)"
|
||||||
formatValue={(v) => formatBytes(v)}
|
formatValue={(v) => formatBytes(v)}
|
||||||
/>
|
/>
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title={t('graphs.network_in')}
|
title={t('graphs.disk_write')}
|
||||||
data={chartData}
|
data={points}
|
||||||
dataKey="netin"
|
dataKey="diskwrite"
|
||||||
color="hsl(262, 83%, 58%)"
|
color="hsl(0, 84%, 60%)"
|
||||||
formatValue={(v) => formatBytes(v)}
|
formatValue={(v) => `${formatBytes(v)}/s`}
|
||||||
/>
|
|
||||||
<ChartCard
|
|
||||||
title={t('graphs.network_out')}
|
|
||||||
data={chartData}
|
|
||||||
dataKey="netout"
|
|
||||||
color="hsl(25, 95%, 53%)"
|
|
||||||
formatValue={(v) => formatBytes(v)}
|
|
||||||
/>
|
/>
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title={t('graphs.disk_read')}
|
title={t('graphs.disk_read')}
|
||||||
data={chartData}
|
data={points}
|
||||||
dataKey="diskread"
|
dataKey="diskread"
|
||||||
color="hsl(199, 89%, 48%)"
|
color="hsl(199, 89%, 48%)"
|
||||||
formatValue={(v) => formatBytes(v)}
|
formatValue={(v) => `${formatBytes(v)}/s`}
|
||||||
/>
|
/>
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title={t('graphs.disk_write')}
|
title={t('graphs.network_in')}
|
||||||
data={chartData}
|
data={points}
|
||||||
dataKey="diskwrite"
|
dataKey="netin"
|
||||||
color="hsl(0, 84%, 60%)"
|
color="hsl(262, 83%, 58%)"
|
||||||
formatValue={(v) => formatBytes(v)}
|
formatValue={(v) => `${formatBytes(v)}/s`}
|
||||||
|
headerRight={nicSelector}
|
||||||
|
/>
|
||||||
|
<ChartCard
|
||||||
|
title={t('graphs.network_out')}
|
||||||
|
data={points}
|
||||||
|
dataKey="netout"
|
||||||
|
color="hsl(25, 95%, 53%)"
|
||||||
|
formatValue={(v) => `${formatBytes(v)}/s`}
|
||||||
|
headerRight={nicSelector}
|
||||||
|
/>
|
||||||
|
<ChartCard
|
||||||
|
title="IOPS чтение"
|
||||||
|
data={points}
|
||||||
|
dataKey="iops_read"
|
||||||
|
color="hsl(180, 70%, 40%)"
|
||||||
|
formatValue={(v) => `${v.toFixed(0)} ops/s`}
|
||||||
|
unit=" ops/s"
|
||||||
|
/>
|
||||||
|
<ChartCard
|
||||||
|
title="IOPS запись"
|
||||||
|
data={points}
|
||||||
|
dataKey="iops_write"
|
||||||
|
color="hsl(300, 60%, 50%)"
|
||||||
|
formatValue={(v) => `${v.toFixed(0)} ops/s`}
|
||||||
|
unit=" ops/s"
|
||||||
|
/>
|
||||||
|
<ChartCard
|
||||||
|
title="Заполненность диска"
|
||||||
|
data={points}
|
||||||
|
dataKey="diskpct"
|
||||||
|
color="hsl(45, 93%, 47%)"
|
||||||
|
formatValue={(v) => `${v.toFixed(1)}%`}
|
||||||
|
unit="%"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Cpu, MemoryStick, HardDrive, Clock, Wifi, Globe, Server, ArrowDown, ArrowUp } from 'lucide-react';
|
import { Cpu, MemoryStick, HardDrive, Clock, Wifi, Globe, Server, ArrowDown, ArrowUp } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useVMLiveMetrics, useVMConfig, useVMInterfaces } from '@/hooks/use-instances';
|
import { useVMLiveMetrics, useVMConfig, useVMInterfaces } from '@/hooks/use-instances';
|
||||||
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
|
import { formatBytes, formatUptime, formatPercent, vmVcpuCount } from '@/lib/format';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { DiskInfo } from '@/types';
|
import type { DiskInfo } from '@/types';
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
|||||||
<MetricCard
|
<MetricCard
|
||||||
label={t('common.cpu')}
|
label={t('common.cpu')}
|
||||||
value={formatPercent(cpuPercent)}
|
value={formatPercent(cpuPercent)}
|
||||||
subtitle={config?.cores ? `${config.cores} ${t('common.cores')}` : undefined}
|
subtitle={vmVcpuCount(config) ? `${vmVcpuCount(config)} ${t('common.cores')}` : undefined}
|
||||||
percent={cpuPercent}
|
percent={cpuPercent}
|
||||||
icon={Cpu}
|
icon={Cpu}
|
||||||
/>
|
/>
|
||||||
@@ -258,7 +258,7 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
|||||||
<InfoRow label={t('common.vmid')} value={vmid} />
|
<InfoRow label={t('common.vmid')} value={vmid} />
|
||||||
<InfoRow label={t('common.node')} value={node} />
|
<InfoRow label={t('common.node')} value={node} />
|
||||||
<InfoRow label={t('common.type')} value={type === 'qemu' ? t('common.qemu_virtual_machine') : t('common.lxc_container')} />
|
<InfoRow label={t('common.type')} value={type === 'qemu' ? t('common.qemu_virtual_machine') : t('common.lxc_container')} />
|
||||||
<InfoRow label={t('common.vcpu')} value={config?.cores} />
|
<InfoRow label={t('common.vcpu')} value={vmVcpuCount(config)} />
|
||||||
<InfoRow label={t('common.memory')} value={config?.memory ? `${config.memory} MB` : undefined} />
|
<InfoRow label={t('common.memory')} value={config?.memory ? `${config.memory} MB` : undefined} />
|
||||||
<InfoRow label={t('common.os_type')} value={config?.ostype as string} />
|
<InfoRow label={t('common.os_type')} value={config?.ostype as string} />
|
||||||
<InfoRow label={t('common.boot_order')} value={config?.boot as string} />
|
<InfoRow label={t('common.boot_order')} value={config?.boot as string} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { getTasksWebSocket } from '@/lib/websocket';
|
import { getTasksWebSocket } from '@/lib/websocket';
|
||||||
@@ -12,7 +12,6 @@ export const vmKeys = {
|
|||||||
detail: (serverId: number, vmid: number) => ['vm', serverId, vmid] as const,
|
detail: (serverId: number, vmid: number) => ['vm', serverId, vmid] as const,
|
||||||
config: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'config'] as const,
|
config: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'config'] as const,
|
||||||
status: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'status'] as const,
|
status: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'status'] as const,
|
||||||
rrddata: (serverId: number, vmid: number, tf: string) => ['vm', serverId, vmid, 'rrddata', tf] as const,
|
|
||||||
snapshots: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'snapshots'] as const,
|
snapshots: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'snapshots'] as const,
|
||||||
interfaces: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'interfaces'] as const,
|
interfaces: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'interfaces'] as const,
|
||||||
};
|
};
|
||||||
@@ -178,14 +177,39 @@ export function useVMConfig(serverId: number, vmid: number, type: string, node:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useVMRrddata(serverId: number, vmid: number, type: string, node: string, timeframe = 'hour') {
|
|
||||||
|
export interface MetricPoint {
|
||||||
|
time: number;
|
||||||
|
cpu: number | null; mem: number | null; maxmem: number | null;
|
||||||
|
netin: number | null; netout: number | null;
|
||||||
|
diskread: number | null; diskwrite: number | null;
|
||||||
|
diskpct: number | null; iops_read: number | null; iops_write: number | null;
|
||||||
|
}
|
||||||
|
export interface MetricsResponse {
|
||||||
|
data: MetricPoint[]; from: number; to: number; meta: { nics: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVMMetrics(
|
||||||
|
serverId: number, vmid: number, type: string, node: string,
|
||||||
|
opts: { timeframe?: string; from?: number; to?: number; nic?: string },
|
||||||
|
) {
|
||||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||||
return useQuery({
|
const { timeframe = 'hour', from, to, nic = 'all' } = opts;
|
||||||
queryKey: vmKeys.rrddata(serverId, vmid, timeframe),
|
return useQuery<MetricsResponse>({
|
||||||
queryFn: async () => {
|
queryKey: ['vm', serverId, vmid, 'metrics', { timeframe, from, to, nic }],
|
||||||
const res = await apiClient.get<{ data: unknown[]; timeframe: string }>(`/proxmox/api/${serverId}/${prefix}/${vmid}/rrddata?node=${node}&timeframe=${timeframe}`);
|
queryFn: () => {
|
||||||
return res.data ?? [];
|
const qs = new URLSearchParams({ node, nic });
|
||||||
|
if (from != null && to != null) {
|
||||||
|
qs.set('from_ts', String(from));
|
||||||
|
qs.set('to_ts', String(to));
|
||||||
|
} else {
|
||||||
|
qs.set('timeframe', timeframe);
|
||||||
|
}
|
||||||
|
return apiClient.get(`/proxmox/api/${serverId}/${prefix}/${vmid}/metrics?${qs}`);
|
||||||
},
|
},
|
||||||
|
// Keep showing the previous series while a new range/NIC loads, so switching
|
||||||
|
// the interface selector doesn't blank the whole grid into a spinner.
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +298,11 @@ export function useSetVMOwner(serverId: number, vmid: number) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (user_id: number | null) =>
|
mutationFn: (user_id: number | null) =>
|
||||||
apiClient.put(`/proxmox/api/${serverId}/vm/${vmid}/owner`, { user_id }),
|
apiClient.put(`/proxmox/api/${serverId}/vm/${vmid}/owner`, { user_id }),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['vm-owner', serverId, vmid] }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['vm-owner', serverId, vmid] });
|
||||||
|
// Refresh the instances list so the owner column updates without a manual reload.
|
||||||
|
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,24 @@ export function useGlobalRealtimeSync() {
|
|||||||
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unsubVmOwnerChanged = ws.onType('vm_owner_changed', (data: unknown) => {
|
||||||
|
const msg = data as {
|
||||||
|
vmid: number;
|
||||||
|
server_id: number;
|
||||||
|
owner_id: number | null;
|
||||||
|
owner_user: { username: string; email?: string; full_name?: string } | null;
|
||||||
|
};
|
||||||
|
qc.setQueryData<Array<Record<string, unknown>>>(vmKeys.all, (old) =>
|
||||||
|
old?.map((vm) =>
|
||||||
|
vm.server_id === msg.server_id && vm.vmid === msg.vmid
|
||||||
|
? { ...vm, owner_id: msg.owner_id, owner_user: msg.owner_user }
|
||||||
|
: vm
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// Keep the admin owner panel (settings tab) in sync as well.
|
||||||
|
qc.invalidateQueries({ queryKey: ['vm-owner', msg.server_id, msg.vmid] });
|
||||||
|
});
|
||||||
|
|
||||||
// ── Server events ──────────────────────────────────────────────────────
|
// ── Server events ──────────────────────────────────────────────────────
|
||||||
const invalidateServerCaches = () => {
|
const invalidateServerCaches = () => {
|
||||||
qc.invalidateQueries({ queryKey: nodeKeys.servers });
|
qc.invalidateQueries({ queryKey: nodeKeys.servers });
|
||||||
@@ -74,6 +92,7 @@ export function useGlobalRealtimeSync() {
|
|||||||
unsubVmStatus();
|
unsubVmStatus();
|
||||||
unsubVmDeleted();
|
unsubVmDeleted();
|
||||||
unsubVmCreated();
|
unsubVmCreated();
|
||||||
|
unsubVmOwnerChanged();
|
||||||
unsubServerAdded();
|
unsubServerAdded();
|
||||||
unsubServerUpdated();
|
unsubServerUpdated();
|
||||||
unsubServerDeleted();
|
unsubServerDeleted();
|
||||||
|
|||||||
@@ -28,19 +28,32 @@ export function vmTypeLabel(type: string): string {
|
|||||||
return type === 'qemu' ? 'VM' : type === 'lxc' ? 'LXC' : type.toUpperCase();
|
return type === 'qemu' ? 'VM' : type === 'lxc' ? 'LXC' : type.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total vCPU count from a live Proxmox config.
|
||||||
|
* QEMU reports `cores` *per socket*, so the real total is `sockets * cores`
|
||||||
|
* (LXC has no sockets and reports the total in `cores`).
|
||||||
|
*/
|
||||||
|
export function vmVcpuCount(config?: { cores?: number; sockets?: number } | null): number | undefined {
|
||||||
|
if (!config?.cores) return undefined;
|
||||||
|
return config.cores * (config.sockets ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a VM/LXC hardware configuration as "N vCPU / X GB RAM / Y GB".
|
* Formats a VM/LXC hardware configuration as "N vCPU / X GB RAM / Y GB".
|
||||||
* `memory` is expected in MB; disk in GB (falls back to `maxdisk` in bytes).
|
* `memory` and `maxdisk` come from the cached instances list in bytes;
|
||||||
|
* `disk_size` (when present) is already in GB.
|
||||||
*/
|
*/
|
||||||
export function formatVmConfig(vm: {
|
export function formatVmConfig(vm: {
|
||||||
cores?: number;
|
cores?: number;
|
||||||
memory?: number;
|
memory?: number;
|
||||||
|
maxmem?: number;
|
||||||
disk_size?: number;
|
disk_size?: number;
|
||||||
maxdisk?: number;
|
maxdisk?: number;
|
||||||
}): string {
|
}): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (vm.cores) parts.push(`${vm.cores} vCPU`);
|
if (vm.cores) parts.push(`${vm.cores} vCPU`);
|
||||||
if (vm.memory) parts.push(`${Math.round(vm.memory / 1024)} GB RAM`);
|
const memBytes = vm.memory || vm.maxmem;
|
||||||
|
if (memBytes) parts.push(`${formatBytes(memBytes, 0)} RAM`);
|
||||||
const diskGb = vm.disk_size ?? (vm.maxdisk ? Math.round(vm.maxdisk / 1024 ** 3) : 0);
|
const diskGb = vm.disk_size ?? (vm.maxdisk ? Math.round(vm.maxdisk / 1024 ** 3) : 0);
|
||||||
if (diskGb) parts.push(`${diskGb} GB`);
|
if (diskGb) parts.push(`${diskGb} GB`);
|
||||||
return parts.join(' / ');
|
return parts.join(' / ');
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ export interface VMInstance {
|
|||||||
|
|
||||||
export interface VMConfig {
|
export interface VMConfig {
|
||||||
cores?: number;
|
cores?: number;
|
||||||
|
/** QEMU socket count; total vCPU = sockets * cores (defaults to 1) */
|
||||||
|
sockets?: number;
|
||||||
|
/** QEMU hotplugged vCPU count, when fewer than sockets * cores are online */
|
||||||
|
vcpus?: number;
|
||||||
memory?: number;
|
memory?: number;
|
||||||
disk?: string;
|
disk?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user