"""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