feat: implementa webapp FastAPI con API swagger e test
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user