Newer
Older
hard-panel / panel / backend / app / api / services.py
"""Health-чеки сервисов: CRUD + история откликов.

URL опрашивает services_probe (lifespan, каждые GHARD_HEALTH_INTERVAL сек,
параллельно по сервисам); точки лежат в service_samples. Статус: up/degraded
(свежая точка этого state), down (свежая down-точка), pending (точек нет или
пробер молчит — нет свежих данных). Классификация отклика — прогрессивный
минимум из services_probe (docs/health-endpoint-spec.md).
"""

import json
from datetime import datetime, timezone

from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field

from app.config import get_settings
from app.db import get_db
from app.security import require_admin
from app.services_probe import probe_service_once

router = APIRouter(prefix="/api/v1", dependencies=[Depends(require_admin)])


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


class ServiceCreate(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    url: str = Field(min_length=1, max_length=500)


class ServiceUpdate(BaseModel):
    name: str | None = None
    url: str | None = None


@router.get("/services")
async def list_services() -> list[dict]:
    """Все сервисы + последняя проба (статус как у шар)."""
    db = get_db()
    cursor = await db.execute(
        """SELECT sv.*, p.ts AS p_ts, p.state AS p_state, p.code AS p_code,
                  p.latency_ms AS p_latency, p.message AS p_message, p.report AS p_report
           FROM services sv
           LEFT JOIN service_samples p ON p.id = (
               SELECT MAX(id) FROM service_samples WHERE service_id = sv.id
           )
           ORDER BY sv.name"""
    )
    rows = await cursor.fetchall()
    stale_after = max(get_settings().health_interval * 3, 180)
    result = []
    for row in rows:
        status = "pending"
        if row["p_ts"] is not None:
            age = (datetime.now(timezone.utc) - datetime.fromisoformat(row["p_ts"])).total_seconds()
            if age <= stale_after:
                status = row["p_state"]  # up | degraded | down
        try:
            report = json.loads(row["p_report"]) if row["p_report"] else None
        except ValueError:
            report = None
        result.append(
            {
                "id": row["id"],
                "name": row["name"],
                "url": row["url"],
                "created_at": row["created_at"],
                "status": status,
                "last_sample": row["p_ts"],
                "latency_ms": row["p_latency"] if status != "pending" else None,
                "code": row["p_code"] if status != "pending" else None,
                "message": row["p_message"] if status != "pending" else "",
                "report": report,
            }
        )
    return result


@router.post("/services", status_code=201)
async def create_service(body: ServiceCreate) -> dict:
    db = get_db()
    cursor = await db.execute(
        "INSERT INTO services (name, url, created_at) VALUES (?, ?, ?)",
        (body.name, body.url, _now_iso()),
    )
    await db.commit()
    # первая проба сразу — не ждём ближайшей волны пробера
    try:
        await probe_service_once(cursor.lastrowid, body.url)
    except Exception as exc:  # замер доберёт фоновый цикл
        print(f"services probe error (on create): {exc}", flush=True)
    return {"id": cursor.lastrowid, "name": body.name, "url": body.url}


@router.patch("/services/{service_id}")
async def update_service(service_id: int, body: ServiceUpdate) -> dict:
    if body.name is None and body.url is None:
        raise HTTPException(status_code=422, detail="nothing to update")
    db = get_db()
    if body.name is not None:
        await db.execute("UPDATE services SET name = ? WHERE id = ?", (body.name, service_id))
    if body.url is not None:
        await db.execute("UPDATE services SET url = ? WHERE id = ?", (body.url, service_id))
    cursor = await db.execute("SELECT changes()")
    (changed,) = await cursor.fetchone()
    if not changed:
        raise HTTPException(status_code=404, detail="service not found")
    await db.commit()
    probe = None
    if body.url is not None:  # URL сменился — пробуем не дожидаясь волны
        try:
            probe = await probe_service_once(service_id, body.url)
        except Exception as exc:
            print(f"services probe error (on update): {exc}", flush=True)
    cursor = await db.execute("SELECT id, name, url, created_at FROM services WHERE id = ?", (service_id,))
    return dict(await cursor.fetchone()) | ({"probe": probe} if probe else {})


@router.delete("/services/{service_id}", status_code=204)
async def delete_service(service_id: int) -> None:
    db = get_db()
    cursor = await db.execute("DELETE FROM services WHERE id = ?", (service_id,))
    await db.commit()
    if cursor.rowcount == 0:
        raise HTTPException(status_code=404, detail="service not found")


@router.get("/services/{service_id}/samples")
async def service_samples(
    service_id: int,
    since: datetime | None = Query(default=None),
    limit: int = Query(default=500, ge=1, le=5000),
) -> list[dict]:
    """История проб (для графика латентности/статусов). По возрастанию ts."""
    db = get_db()
    conditions = ["service_id = ?"]
    params: list = [service_id]
    if since is not None:
        conditions.append("ts >= ?")
        params.append(since.astimezone(timezone.utc).isoformat())
    params.append(limit)
    cursor = await db.execute(
        f"SELECT ts, state, code, latency_ms, report FROM service_samples WHERE {' AND '.join(conditions)}"
        " ORDER BY ts DESC LIMIT ?",
        params,
    )
    rows = await cursor.fetchall()
    points = []
    for row in reversed(rows):
        try:
            report = json.loads(row["report"]) if row["report"] else None
        except ValueError:
            report = None
        points.append(
            {
                "ts": row["ts"],
                "state": row["state"],
                "code": row["code"],
                "latency_ms": row["latency_ms"],
                "report": report,
            }
        )
    return points