"""Servers API: ключи агентов, карточки серверов, история метрик.
Этап 1: доступ по Bearer GHARD_ADMIN_TOKEN. На этапе 3 (веб-интерфейс)
заменяем на JWT-сессию.
"""
import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from app.config import get_settings
from app.db import get_db, j
from app.models import ServerCreate, ServerUpdate
from app.security import generate_server_key, hash_key, require_admin
router = APIRouter(prefix="/api/v1", dependencies=[Depends(require_admin)])
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _server_status(server, interval: int) -> str:
"""online/offline/pending — по возрасту last_seen против interval агента."""
if not server["last_seen"]:
return "pending"
threshold = max(server["interval"] * get_settings().offline_multiplier, 120)
age = datetime.now(timezone.utc) - datetime.fromisoformat(server["last_seen"])
return "online" if age.total_seconds() <= threshold + 60 else "offline"
def _server_dict(server) -> dict:
return {
"id": server["id"],
"name": server["name"],
"hostname": server["hostname"],
"os": server["os"],
"kernel": server["kernel"],
"ips": json.loads(server["ips_json"]),
"source_ip": server["source_ip"],
"note": server["note"],
"interval": server["interval"],
"created_at": server["created_at"],
"last_seen": server["last_seen"],
"status": _server_status(server, server["interval"]),
}
@router.post("/servers", status_code=201)
async def create_server(body: ServerCreate) -> dict:
"""Создать сервер. Plaintext-ключ возвращается ТОЛЬКО здесь, один раз."""
key = generate_server_key()
db = get_db()
cursor = await db.execute(
"""INSERT INTO servers (name, hostname, key_hash, interval, created_at)
VALUES (?, ?, ?, ?, ?)""",
(body.name, body.hostname, hash_key(key), body.interval, _now_iso()),
)
await db.commit()
return {"id": cursor.lastrowid, "name": body.name, "key": key}
@router.get("/servers")
async def list_servers() -> list[dict]:
"""Сводка по всем серверам + последняя точка метрик (для дашборда)."""
db = get_db()
cursor = await db.execute(
"""SELECT s.*, m.cpu, m.load1, m.load5, m.load15, m.ram_used, m.ram_total,
m.swap_used, m.swap_total, m.uptime, m.net_in_mbs, m.net_out_mbs,
m.disks_json
FROM servers s
LEFT JOIN metrics m ON m.id = (
SELECT MAX(id) FROM metrics WHERE server_id = s.id
)
ORDER BY s.name"""
)
rows = await cursor.fetchall()
result = []
for server in rows:
item = _server_dict(server)
item.update(
{
"metrics": None
if server["cpu"] is None
else {
"cpu": server["cpu"],
"load": [server["load1"], server["load5"], server["load15"]],
"ram": {
"used": server["ram_used"],
"total": server["ram_total"],
},
"swap": {
"used": server["swap_used"],
"total": server["swap_total"],
},
"uptime": server["uptime"],
"net_in_mbs": server["net_in_mbs"],
"net_out_mbs": server["net_out_mbs"],
"disks": json.loads(server["disks_json"]),
}
}
)
result.append(item)
return result
@router.get("/servers/{server_id}")
async def get_server(server_id: int) -> dict:
db = get_db()
cursor = await db.execute("SELECT * FROM servers WHERE id = ?", (server_id,))
server = await cursor.fetchone()
if server is None:
raise HTTPException(status_code=404, detail="server not found")
item = _server_dict(server)
cursor = await db.execute(
"SELECT * FROM metrics WHERE server_id = ? ORDER BY id DESC LIMIT 1",
(server_id,),
)
metrics = await cursor.fetchone()
if metrics is not None:
item["metrics"] = {
"ts": metrics["ts"],
"cpu": metrics["cpu"],
"load": [metrics["load1"], metrics["load5"], metrics["load15"]],
"ram": {"used": metrics["ram_used"], "total": metrics["ram_total"]},
"swap": {"used": metrics["swap_used"], "total": metrics["swap_total"]},
"uptime": metrics["uptime"],
"net_in_mbs": metrics["net_in_mbs"],
"net_out_mbs": metrics["net_out_mbs"],
"disks": json.loads(metrics["disks_json"]),
"processes": json.loads(metrics["processes_json"]),
"docker": json.loads(metrics["docker_json"]),
"extra": json.loads(metrics["extra_json"]),
}
else:
item["metrics"] = None
return item
@router.patch("/servers/{server_id}")
async def update_server(server_id: int, body: ServerUpdate) -> dict:
"""Пока меняем только то, что редактируется в UI: имя и заметку."""
if body.name is None and body.note is None:
raise HTTPException(status_code=422, detail="nothing to update")
db = get_db()
if body.name is not None:
await db.execute("UPDATE servers SET name = ? WHERE id = ?", (body.name, server_id))
if body.note is not None:
await db.execute("UPDATE servers SET note = ? WHERE id = ?", (body.note, server_id))
cursor = await db.execute("SELECT changes()")
(changed,) = await cursor.fetchone()
if not changed:
raise HTTPException(status_code=404, detail="server not found")
await db.commit()
cursor = await db.execute("SELECT * FROM servers WHERE id = ?", (server_id,))
return _server_dict(await cursor.fetchone())
@router.delete("/servers/{server_id}", status_code=204)
async def delete_server(server_id: int) -> None:
db = get_db()
cursor = await db.execute("DELETE FROM servers WHERE id = ?", (server_id,))
await db.commit()
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail="server not found")
@router.get("/servers/{server_id}/metrics")
async def server_metrics(
server_id: int,
since: datetime | None = Query(default=None),
until: datetime | None = Query(default=None),
limit: int = Query(default=500, ge=1, le=5000),
) -> list[dict]:
"""История метрик (графики): время, cpu, ram, сеть. По возрастанию ts."""
db = get_db()
conditions = ["server_id = ?"]
params: list = [server_id]
if since is not None:
conditions.append("ts >= ?")
params.append(since.astimezone(timezone.utc).isoformat())
if until is not None:
conditions.append("ts <= ?")
params.append(until.astimezone(timezone.utc).isoformat())
params.append(limit)
cursor = await db.execute(
f"""SELECT ts, cpu, load1, load5, load15, ram_used, ram_total,
swap_used, swap_total, uptime, net_in_mbs, net_out_mbs, disks_json
FROM metrics WHERE {' AND '.join(conditions)}
ORDER BY ts DESC LIMIT ?""",
params,
)
rows = await cursor.fetchall()
return [
{
"ts": row["ts"],
"cpu": row["cpu"],
"load": [row["load1"], row["load5"], row["load15"]],
"ram": {"used": row["ram_used"], "total": row["ram_total"]},
"swap": {"used": row["swap_used"], "total": row["swap_total"]},
"uptime": row["uptime"],
"net_in_mbs": row["net_in_mbs"],
"net_out_mbs": row["net_out_mbs"],
"disks": json.loads(row["disks_json"]),
}
for row in reversed(rows)
]