"""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 urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, field_validator
from app.config import get_settings
from app.db import get_db
from app.external_probe import probe_service_once as probe_external_once
from app.security import require_admin
from app.service_meta import refresh_meta_now
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()
def _public_url(value: str) -> str:
"""Публичный URL: только https, без логина/фрагмента, порт 443 (или пусто)."""
value = value.strip()
if not value:
return ""
parsed = urlparse(value)
if parsed.scheme != "https" or not parsed.hostname:
raise ValueError("public_url: нужен https:// с доменом")
if parsed.username or parsed.password:
raise ValueError("public_url: логин/пароль в URL не нужны")
if parsed.fragment:
raise ValueError("public_url: фрагмент (#...) на сервер не уходит")
try:
port = parsed.port
except ValueError as exc:
raise ValueError("public_url: битый порт") from exc
if port not in (None, 443):
raise ValueError("public_url: проверяется публичный вход :443")
return value
class ServiceCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
url: str = Field(min_length=1, max_length=500)
public_url: str = Field(default="", max_length=500)
@field_validator("public_url")
@classmethod
def _check_public_url(cls, value: str) -> str:
return _public_url(value)
class ServiceUpdate(BaseModel):
name: str | None = None
url: str | None = None
public_url: str | None = Field(default=None, max_length=500)
@field_validator("public_url")
@classmethod
def _check_public_url(cls, value: str | None) -> str | None:
return None if value is None else _public_url(value)
@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,
e.ts AS e_ts, e.state AS e_state, e.code AS e_code,
e.latency_ms AS e_latency, e.cert_days AS e_cert_days,
e.dns_ips AS e_dns_ips, e.message AS e_message
FROM services sv
LEFT JOIN service_samples p ON p.id = (
SELECT MAX(id) FROM service_samples WHERE service_id = sv.id
)
LEFT JOIN service_external_samples e ON e.id = (
SELECT MAX(id) FROM service_external_samples WHERE service_id = sv.id
)
ORDER BY sv.name"""
)
rows = await cursor.fetchall()
settings = get_settings()
stale_after = max(settings.health_interval * 3, 180)
external_stale_after = max(settings.external_interval * 3, 180)
now = datetime.now(timezone.utc)
result = []
for row in rows:
status = "pending"
if row["p_ts"] is not None:
age = (now - 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
external = None
if row["public_url"]:
external_status = "pending"
if row["e_ts"] is not None:
age = (now - datetime.fromisoformat(row["e_ts"])).total_seconds()
if age <= external_stale_after:
external_status = row["e_state"]
fresh = external_status != "pending"
try:
dns_ips = json.loads(row["e_dns_ips"]) if row["e_dns_ips"] else []
except ValueError:
dns_ips = []
external = {
"status": external_status,
"checked_at": row["e_ts"] if fresh else None,
"latency_ms": row["e_latency"] if fresh else None,
"code": row["e_code"] if fresh else None,
"dns_ips": dns_ips if fresh else [],
"cert_days": row["e_cert_days"] if fresh else None,
"message": row["e_message"] if fresh else "",
}
result.append(
{
"id": row["id"],
"name": row["name"],
"url": row["url"],
"public_url": row["public_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,
"external": external,
"site_title": row["site_title"],
"icon": row["icon"],
}
)
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, public_url, created_at) VALUES (?, ?, ?, ?)",
(body.name, body.url, body.public_url, _now_iso()),
)
service_id = cursor.lastrowid
await db.commit()
# первая проба и мета (title/favicon) сразу — не ждём ближайшей волны пробера
try:
await probe_service_once(service_id, body.url)
except Exception as exc: # замер доберёт фоновый цикл
print(f"services probe error (on create): {exc}", flush=True)
try:
await refresh_meta_now(service_id, body.url)
except Exception as exc: # мета доберёт фоновый цикл
print(f"service meta error (on create): {exc}", flush=True)
if body.public_url: # внешняя проверка — тоже сразу
try:
await probe_external_once(service_id)
except Exception as exc:
print(f"external probe error (on create): {exc}", flush=True)
return {
"id": service_id,
"name": body.name,
"url": body.url,
"public_url": body.public_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 and body.public_url is None:
raise HTTPException(status_code=422, detail="nothing to update")
db = get_db()
cursor = await db.execute("SELECT public_url FROM services WHERE id = ?", (service_id,))
row = await cursor.fetchone()
if row is None:
raise HTTPException(status_code=404, detail="service not found")
public_changed = body.public_url is not None and row["public_url"] != body.public_url
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:
# старая мета могла описывать чужой сайт — сброс до тяги по новому URL
await db.execute(
"UPDATE services SET url = ?, site_title = NULL, icon = NULL, meta_fetched_at = NULL"
" WHERE id = ?",
(body.url, service_id),
)
if public_changed:
# состояние алертов и история относились к другому домену: график не
# должен смешивать два сервиса, а открытый инцидент — переезжать
await db.execute(
"UPDATE services SET public_url = ?, external_bad_since = NULL,"
" external_alert_open = 0 WHERE id = ?",
(body.public_url, service_id),
)
await db.execute(
"DELETE FROM service_external_samples WHERE service_id = ?", (service_id,)
)
await db.commit()
probe = None
if body.url is not None: # URL сменился — пробуем не дожидаясь волны
try:
probe = await probe_service_once(service_id, body.url)
await refresh_meta_now(service_id, body.url)
except Exception as exc:
print(f"services probe error (on update): {exc}", flush=True)
if public_changed and body.public_url:
try:
await probe_external_once(service_id)
except Exception as exc:
print(f"external probe error (on update): {exc}", flush=True)
cursor = await db.execute(
"SELECT id, name, url, public_url, created_at FROM services WHERE id = ?", (service_id,)
)
return dict(await cursor.fetchone()) | ({"probe": probe} if probe else {})
@router.get("/services/{service_id}/incidents")
async def service_incidents(
service_id: int,
limit: int = Query(default=50, ge=1, le=500),
) -> list[dict]:
"""Журнал моментов недоступности.
Период = последовательные не-up пробы (down|degraded); закрывается первой
up-пробой. worst = худшее состояние в периоде (down > degraded); причина —
code/message первой точки периода. end NULL = идёт сейчас.
"""
db = get_db()
cursor = await db.execute("SELECT 1 FROM services WHERE id = ?", (service_id,))
if not await cursor.fetchone():
raise HTTPException(status_code=404, detail="service not found")
cursor = await db.execute(
"SELECT ts, state, code, message FROM service_samples WHERE service_id = ?"
" ORDER BY ts ASC",
(service_id,),
)
rows = await cursor.fetchall()
now_s = datetime.now(timezone.utc).timestamp()
def duration(start: str, end: str) -> float:
span = (datetime.fromisoformat(end) - datetime.fromisoformat(start)).total_seconds()
return max(0.0, round(span, 1))
open_period = None
incidents: list[dict] = []
for row in rows:
if row["state"] == "up":
if open_period is not None:
open_period["end"] = row["ts"]
open_period["duration_s"] = duration(open_period["start"], row["ts"])
incidents.append(open_period)
open_period = None
elif open_period is None:
open_period = {
"start": row["ts"],
"end": None,
"duration_s": None,
"worst": row["state"], # начало периода: что случилось
"code": row["code"],
"message": row["message"],
}
elif row["state"] == "down" and open_period["worst"] != "down":
open_period["worst"] = "down" # деградация могла углубиться до down
if open_period is not None: # продолжается сейчас
open_period["duration_s"] = round(
max(0.0, now_s - datetime.fromisoformat(open_period["start"]).timestamp()), 1
)
incidents.append(open_period)
return list(reversed(incidents))[:limit]
@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
@router.get("/services/{service_id}/external_samples")
async def service_external_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()
cursor = await db.execute("SELECT 1 FROM services WHERE id = ?", (service_id,))
if not await cursor.fetchone():
raise HTTPException(status_code=404, detail="service not found")
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(
"SELECT ts, state, dns_ips, code, latency_ms, cert_days, message"
f" FROM service_external_samples WHERE {' AND '.join(conditions)}"
" ORDER BY ts DESC LIMIT ?",
params,
)
rows = await cursor.fetchall()
points = []
for row in reversed(rows):
try:
dns_ips = json.loads(row["dns_ips"]) if row["dns_ips"] else []
except ValueError:
dns_ips = []
points.append(
{
"ts": row["ts"],
"state": row["state"],
"dns_ips": dns_ips,
"code": row["code"],
"latency_ms": row["latency_ms"],
"cert_days": row["cert_days"],
"message": row["message"],
}
)
return points