feat: implementa webapp FastAPI con API swagger e test

This commit is contained in:
Luca Sacchi Ricciardi
2026-04-24 13:56:32 +02:00
parent 24e7d5eede
commit e8ae3603e7
10 changed files with 509 additions and 137 deletions
+361 -129
View File
@@ -1,25 +1,24 @@
"""
supabase-pinger
---------------
Servizio long-running che esegue periodicamente una query di keep-alive
verso un database Supabase/PostgreSQL per evitarne la sospensione automatica
nel free tier.
"""Supabase pinger con API FastAPI, storage storico a buffer circolare e dashboard."""
Configurazione: file .env (vedi .env.example)
"""
from __future__ import annotations
import logging
import os
import signal
import sqlite3
import sys
import threading
import time
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import psycopg2
import uvicorn
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
@@ -29,27 +28,7 @@ logging.basicConfig(
)
log = logging.getLogger("supabase-pinger")
# ---------------------------------------------------------------------------
# Shutdown ordinato
# ---------------------------------------------------------------------------
_shutdown = False
def _handle_signal(signum, _frame):
global _shutdown
log.info("Segnale di arresto ricevuto (%s). Chiusura in corso...", signum)
_shutdown = True
signal.signal(signal.SIGTERM, _handle_signal)
signal.signal(signal.SIGINT, _handle_signal)
# ---------------------------------------------------------------------------
# Configurazione
# ---------------------------------------------------------------------------
_REQUIRED_VARS = [
REQUIRED_VARS = [
"SUPABASE_DB_HOST",
"SUPABASE_DB_PORT",
"SUPABASE_DB_NAME",
@@ -58,79 +37,171 @@ _REQUIRED_VARS = [
]
def load_config() -> dict:
"""Carica e valida la configurazione dal file .env o dall'ambiente."""
@dataclass
class Settings:
db_host: str
db_port: int
db_name: str
db_user: str
db_password: str
ping_query: str
ping_interval_minutes: int
web_host: str
web_port: int
rrd_db_path: str
rrd_retention_hours: int
@property
def max_samples(self) -> int:
samples = int((self.rrd_retention_hours * 60) / self.ping_interval_minutes)
return max(1, samples)
def load_config() -> Settings:
"""Carica e valida la configurazione da ambiente/.env."""
load_dotenv()
missing = [v for v in _REQUIRED_VARS if not os.environ.get(v)]
missing = [name for name in REQUIRED_VARS if not os.environ.get(name)]
if missing:
log.error(
"Variabili di ambiente obbligatorie mancanti: %s", ", ".join(missing)
raise RuntimeError(
f"Variabili obbligatorie mancanti: {', '.join(missing)}"
)
sys.exit(1)
try:
port = int(os.environ["SUPABASE_DB_PORT"])
except ValueError:
log.error("SUPABASE_DB_PORT deve essere un numero intero.")
sys.exit(1)
try:
interval = int(os.environ.get("PING_INTERVAL_MINUTES", "4320"))
if interval <= 0:
db_port = int(os.environ["SUPABASE_DB_PORT"])
ping_interval = int(os.environ.get("PING_INTERVAL_MINUTES", "4320"))
web_port = int(os.environ.get("WEB_PORT", "8080"))
retention_hours = int(os.environ.get("RRD_RETENTION_HOURS", "48"))
if ping_interval <= 0 or web_port <= 0 or retention_hours <= 0:
raise ValueError
except ValueError:
log.error("PING_INTERVAL_MINUTES deve essere un numero intero positivo.")
sys.exit(1)
except ValueError as exc:
raise RuntimeError(
"SUPABASE_DB_PORT, PING_INTERVAL_MINUTES, WEB_PORT e "
"RRD_RETENTION_HOURS devono essere interi positivi."
) from exc
return {
"host": os.environ["SUPABASE_DB_HOST"],
"port": port,
"dbname": os.environ["SUPABASE_DB_NAME"],
"user": os.environ["SUPABASE_DB_USER"],
"password": os.environ["SUPABASE_DB_PASSWORD"],
"query": os.environ.get("PING_QUERY", "SELECT 1;"),
"interval_minutes": interval,
}
# ---------------------------------------------------------------------------
# Keep-alive
# ---------------------------------------------------------------------------
return Settings(
db_host=os.environ["SUPABASE_DB_HOST"],
db_port=db_port,
db_name=os.environ["SUPABASE_DB_NAME"],
db_user=os.environ["SUPABASE_DB_USER"],
db_password=os.environ["SUPABASE_DB_PASSWORD"],
ping_query=os.environ.get("PING_QUERY", "SELECT 1;"),
ping_interval_minutes=ping_interval,
web_host=os.environ.get("WEB_HOST", "0.0.0.0"),
web_port=web_port,
rrd_db_path=os.environ.get("RRD_DB_PATH", "data/connection_rrd.sqlite3"),
rrd_retention_hours=retention_hours,
)
def ping(cfg: dict) -> bool:
"""
Apre una connessione, esegue la query di keep-alive e chiude tutto.
Restituisce True in caso di successo, False in caso di errore.
Non espone mai credenziali nei log.
"""
class RRDStore:
"""Storage storico a buffer circolare, dimensionato a finestra 48h."""
def __init__(self, path: str, max_samples: int) -> None:
self.path = path
self.max_samples = max_samples
Path(path).parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.path)
conn.row_factory = sqlite3.Row
return conn
def _init_db(self) -> None:
with self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
success INTEGER NOT NULL,
latency_ms REAL,
error_message TEXT
);
"""
)
conn.commit()
def add_sample(
self, *, ts: int, success: bool, latency_ms: float | None, error_message: str | None
) -> None:
with self._connect() as conn:
conn.execute(
"""
INSERT INTO samples (ts, success, latency_ms, error_message)
VALUES (?, ?, ?, ?)
""",
(ts, 1 if success else 0, latency_ms, error_message),
)
conn.execute(
"""
DELETE FROM samples
WHERE id NOT IN (
SELECT id FROM samples ORDER BY id DESC LIMIT ?
)
""",
(self.max_samples,),
)
conn.commit()
def latest(self) -> dict[str, Any] | None:
with self._connect() as conn:
row = conn.execute(
"""
SELECT ts, success, latency_ms, error_message
FROM samples
ORDER BY id DESC
LIMIT 1
"""
).fetchone()
return self._row_to_dict(row) if row else None
def history(self, hours: int) -> list[dict[str, Any]]:
min_ts = int(time.time()) - (hours * 3600)
with self._connect() as conn:
rows = conn.execute(
"""
SELECT ts, success, latency_ms, error_message
FROM samples
WHERE ts >= ?
ORDER BY ts ASC
""",
(min_ts,),
).fetchall()
return [self._row_to_dict(row) for row in rows]
@staticmethod
def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
return {
"timestamp": int(row["ts"]),
"success": bool(row["success"]),
"latency_ms": row["latency_ms"],
"error_message": row["error_message"],
}
def run_ping(settings: Settings) -> tuple[bool, float | None, str | None]:
"""Esegue il keep-alive e restituisce esito, latenza, eventuale errore."""
conn = None
started = time.perf_counter()
try:
log.info(
"Connessione a %s:%d/%s...",
cfg["host"],
cfg["port"],
cfg["dbname"],
)
conn = psycopg2.connect(
host=cfg["host"],
port=cfg["port"],
dbname=cfg["dbname"],
user=cfg["user"],
password=cfg["password"],
host=settings.db_host,
port=settings.db_port,
dbname=settings.db_name,
user=settings.db_user,
password=settings.db_password,
connect_timeout=10,
)
with conn.cursor() as cur:
log.info("Esecuzione query di keep-alive: %s", cfg["query"])
cur.execute(cfg["query"])
log.info("Keep-alive eseguito con successo.")
return True
except psycopg2.OperationalError as exc:
log.error("Errore di connessione al database: %s", exc)
return False
cur.execute(settings.ping_query)
latency_ms = (time.perf_counter() - started) * 1000
return True, latency_ms, None
except psycopg2.Error as exc:
log.error("Errore durante l'esecuzione della query: %s", exc)
return False
return False, None, str(exc)
finally:
if conn is not None:
try:
@@ -138,52 +209,213 @@ def ping(cfg: dict) -> bool:
except Exception:
pass
# ---------------------------------------------------------------------------
# Loop principale
# ---------------------------------------------------------------------------
def collector_loop(settings: Settings, store: RRDStore, stop_event: threading.Event) -> None:
interval_seconds = settings.ping_interval_minutes * 60
log.info(
"Collector keep-alive avviato (interval=%s min, retention=%s ore, max_samples=%s)",
settings.ping_interval_minutes,
settings.rrd_retention_hours,
settings.max_samples,
)
def run(cfg: dict) -> None:
"""Loop principale del servizio."""
interval_seconds = cfg["interval_minutes"] * 60
log.info("=" * 60)
log.info("supabase-pinger avviato")
log.info("Host : %s:%d/%s", cfg["host"], cfg["port"], cfg["dbname"])
log.info("Intervallo : %d minuti", cfg["interval_minutes"])
log.info("Query : %s", cfg["query"])
log.info("=" * 60)
while not _shutdown:
success = ping(cfg)
if not success:
log.warning(
"Keep-alive fallito. Nuovo tentativo al prossimo ciclo."
)
if _shutdown:
break
next_run = time.strftime(
"%Y-%m-%d %H:%M:%S",
time.localtime(time.time() + interval_seconds),
while not stop_event.is_set():
success, latency_ms, error_message = run_ping(settings)
sample_ts = int(time.time())
store.add_sample(
ts=sample_ts,
success=success,
latency_ms=latency_ms,
error_message=error_message,
)
log.info("Prossima esecuzione: %s. In attesa...", next_run)
# Sleep a blocchi per rispondere rapidamente a SIGTERM/SIGINT
elapsed = 0
while elapsed < interval_seconds and not _shutdown:
time.sleep(min(10, interval_seconds - elapsed))
elapsed += 10
if success:
log.info("Keep-alive OK (latency_ms=%.2f)", latency_ms or 0.0)
else:
log.warning("Keep-alive failed: %s", error_message)
log.info("supabase-pinger arrestato.")
wait_seconds = 0
while wait_seconds < interval_seconds and not stop_event.is_set():
time.sleep(min(5, interval_seconds - wait_seconds))
wait_seconds += 5
log.info("Collector keep-alive arrestato")
# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------
def _build_dashboard_html() -> str:
return """
<!doctype html>
<html lang=\"it\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>Supabase Pinger Dashboard</title>
<style>
:root {
--bg: #101418;
--panel: #17202a;
--line: #3ecf8e;
--danger: #ff6b6b;
--text: #edf2f7;
--muted: #9fb3c8;
}
body { margin: 0; font-family: 'Segoe UI', sans-serif; background: radial-gradient(circle at 20% 20%, #1f2937, var(--bg)); color: var(--text); }
.wrap { max-width: 1100px; margin: 0 auto; padding: 24px; }
.panel { background: color-mix(in srgb, var(--panel), #000 15%); border: 1px solid #283544; border-radius: 14px; padding: 18px; }
.top { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.badge { padding: 6px 10px; border-radius: 999px; font-weight: 700; font-size: 13px; }
.ok { background: rgba(62, 207, 142, .15); color: #7ff5bd; }
.ko { background: rgba(255, 107, 107, .15); color: #ff9f9f; }
.meta { color: var(--muted); font-size: 14px; }
canvas { margin-top: 18px; }
</style>
</head>
<body>
<div class=\"wrap\">
<div class=\"panel\">
<div class=\"top\">
<h1>Supabase Pinger - SmokePing Style</h1>
<div>
<span id=\"statusBadge\" class=\"badge\">N/A</span>
</div>
</div>
<div class=\"meta\" id=\"meta\">Caricamento...</div>
<canvas id=\"latencyChart\"></canvas>
</div>
</div>
<script src=\"https://cdn.jsdelivr.net/npm/chart.js\"></script>
<script>
const badge = document.getElementById('statusBadge');
const meta = document.getElementById('meta');
const ctx = document.getElementById('latencyChart').getContext('2d');
const chart = new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: [
{ label: 'Latenza ms', data: [], borderColor: '#3ecf8e', tension: 0.2 },
{ label: 'Errori', data: [], borderColor: '#ff6b6b', stepped: true, yAxisID: 'y1' }
] },
options: {
responsive: true,
scales: {
y: { title: { display: true, text: 'ms' } },
y1: { position: 'right', min: 0, max: 1, ticks: { stepSize: 1 } }
}
}
});
async function load() {
const [statusRes, historyRes] = await Promise.all([
fetch('/api/status'),
fetch('/api/history?hours=48')
]);
const status = await statusRes.json();
const history = await historyRes.json();
if (status.success === true) {
badge.textContent = 'UP';
badge.className = 'badge ok';
} else if (status.success === false) {
badge.textContent = 'DOWN';
badge.className = 'badge ko';
} else {
badge.textContent = 'N/A';
badge.className = 'badge';
}
meta.textContent = `Campioni 48h: ${history.count} | Ultimo campione: ${status.timestamp ? new Date(status.timestamp * 1000).toLocaleString() : 'n/a'}`;
chart.data.labels = history.samples.map(s => new Date(s.timestamp * 1000).toLocaleTimeString());
chart.data.datasets[0].data = history.samples.map(s => s.latency_ms);
chart.data.datasets[1].data = history.samples.map(s => s.success ? 0 : 1);
chart.update();
}
load().catch((err) => {
meta.textContent = `Errore caricamento dashboard: ${err}`;
});
setInterval(() => load().catch(() => {}), 30000);
</script>
</body>
</html>
"""
def create_app(
config_override: Settings | None = None,
start_collector: bool = True,
) -> FastAPI:
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = config_override or load_config()
store = RRDStore(settings.rrd_db_path, settings.max_samples)
app.state.settings = settings
app.state.store = store
app.state.collector_stop_event = threading.Event()
app.state.collector_thread = None
if start_collector:
thread = threading.Thread(
target=collector_loop,
args=(settings, store, app.state.collector_stop_event),
daemon=True,
name="collector-thread",
)
thread.start()
app.state.collector_thread = thread
yield
app.state.collector_stop_event.set()
if app.state.collector_thread is not None:
app.state.collector_thread.join(timeout=5)
app = FastAPI(
title="Supabase Pinger API",
version="1.1.0",
description=(
"API read-only per monitoraggio storico connessione Supabase "
"(retention RRD 48h) e dashboard SmokePing-style."
),
lifespan=lifespan,
)
@app.get("/", response_class=HTMLResponse, tags=["dashboard"])
def dashboard() -> str:
return _build_dashboard_html()
@app.get("/api/status", tags=["monitoring"])
def api_status(request: Request) -> dict[str, Any]:
latest = request.app.state.store.latest()
if latest is None:
return {
"success": None,
"timestamp": None,
"latency_ms": None,
"error_message": "Nessun campione disponibile",
}
return latest
@app.get("/api/history", tags=["monitoring"])
def api_history(
request: Request,
hours: int = Query(48, ge=1, le=48),
) -> dict[str, Any]:
if hours > request.app.state.settings.rrd_retention_hours:
raise HTTPException(status_code=400, detail="Finestra richiesta oltre retention")
samples = request.app.state.store.history(hours)
return {
"hours": hours,
"count": len(samples),
"samples": samples,
}
return app
app = create_app()
if __name__ == "__main__":
config = load_config()
run(config)
cfg = load_config()
uvicorn.run("app:app", host=cfg.web_host, port=cfg.web_port, log_level="info")