feat(metrics): add /metrics read endpoints for vm and container
This commit is contained in:
@@ -1845,6 +1845,58 @@ 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 ...services.metrics_history import timeframe_to_range
|
||||||
|
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)."""
|
||||||
|
from ...services.metrics_history import query_instance_metrics
|
||||||
|
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)."""
|
||||||
|
from ...services.metrics_history import query_instance_metrics
|
||||||
|
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")
|
||||||
@@ -2827,6 +2879,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:
|
||||||
@@ -2835,6 +2888,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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
Reference in New Issue
Block a user