Newer
Older
hard-panel / panel / backend / app / api / servers.py
"""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)
    ]