feat(heartbeats): ping endpoint with silence alerts, menu screen and digest block

This commit is contained in:
hh committed 2026-10-14 05:20:53 +02:00
1 parent 6ef321dc9f
commit 69e7ad79ac
14 files changed
+473 -17

No files matched your search

+17 -1
View File
@@ -7,6 +7,7 @@ from utils.env import env
from utils.logging import logger
from .repositories import (
HeartbeatRepository,
IncidentRepository,
PinRepository,
ServiceRepository,
@@ -14,7 +15,7 @@ from .repositories import (
StatRepository,
)
SCHEMA_VERSION = 2
SCHEMA_VERSION = 3
SCHEMA = """
CREATE TABLE IF NOT EXISTS services (
@@ -67,6 +68,19 @@ CREATE TABLE IF NOT EXISTS hourly (
latency_max INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (service_id, hour)
);
CREATE TABLE IF NOT EXISTS heartbeats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
period INTEGER NOT NULL,
created_at TEXT NOT NULL,
last_ping_at TEXT NOT NULL,
last_ok_at TEXT,
is_overdue INTEGER NOT NULL DEFAULT 0,
alerted_at TEXT
);
"""
MIGRATIONS: tuple[tuple[str, str, str], ...] = (
@@ -102,6 +116,7 @@ class Database:
self._path = path
self._conn: aiosqlite.Connection | None = None
self.services: ServiceRepository = None # type: ignore[assignment]
self.heartbeats: HeartbeatRepository = None # type: ignore[assignment]
self.incidents: IncidentRepository = None # type: ignore[assignment]
self.pins: PinRepository = None # type: ignore[assignment]
self.settings: SettingRepository = None # type: ignore[assignment]
@@ -118,6 +133,7 @@ class Database:
await self._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
await self._conn.commit()
self.services = ServiceRepository(self._conn)
self.heartbeats = HeartbeatRepository(self._conn)
self.incidents = IncidentRepository(self._conn)
self.pins = PinRepository(self._conn)
self.settings = SettingRepository(self._conn)
+27
View File
@@ -15,6 +15,11 @@ class PinMode(StrEnum):
FORM = "form"
class HeartbeatStatus(StrEnum):
OK = "ok"
FAIL = "fail"
@dataclass(slots=True)
class Service:
id: int
@@ -79,6 +84,28 @@ class Pin:
position: int = 0
@dataclass(slots=True)
class Heartbeat:
id: int
name: str
status: HeartbeatStatus
text: str
period: int
created_at: str
last_ping_at: str
last_ok_at: str | None = None
is_overdue: bool = False
alerted_at: str | None = None
@property
def healthy(self) -> bool:
return self.status is HeartbeatStatus.OK and not self.is_overdue
@property
def mark(self) -> str:
return "🟢" if self.healthy else "🔴"
@dataclass(slots=True)
class Hour:
hour: int
+2
View File
@@ -1,3 +1,4 @@
from .heartbeat import HeartbeatRepository
from .incident import IncidentRepository
from .pin import PinRepository
from .service import ServiceRepository
@@ -5,6 +6,7 @@ from .setting import SettingRepository
from .stat import StatRepository
__all__ = [
"HeartbeatRepository",
"IncidentRepository",
"PinRepository",
"ServiceRepository",
+88
View File
@@ -0,0 +1,88 @@
import aiosqlite
from utils.db.models import Heartbeat, HeartbeatStatus
from utils.format import now
_COLUMNS = (
"id, name, status, text, period, created_at, "
"last_ping_at, last_ok_at, is_overdue, alerted_at"
)
def _row_to_heartbeat(row: aiosqlite.Row) -> Heartbeat:
return Heartbeat(
id=row["id"],
name=row["name"],
status=HeartbeatStatus(row["status"]),
text=row["text"],
period=row["period"],
created_at=row["created_at"],
last_ping_at=row["last_ping_at"],
last_ok_at=row["last_ok_at"],
is_overdue=bool(row["is_overdue"]),
alerted_at=row["alerted_at"],
)
class HeartbeatRepository:
def __init__(self, conn: aiosqlite.Connection) -> None:
self._conn = conn
async def all(self) -> list[Heartbeat]:
cursor = await self._conn.execute(
f"SELECT {_COLUMNS} FROM heartbeats ORDER BY name" # noqa: S608
)
return [_row_to_heartbeat(row) for row in await cursor.fetchall()]
async def get(self, heartbeat_id: int) -> Heartbeat | None:
cursor = await self._conn.execute(
f"SELECT {_COLUMNS} FROM heartbeats WHERE id = ?", # noqa: S608
(heartbeat_id,),
)
row = await cursor.fetchone()
return _row_to_heartbeat(row) if row else None
async def by_name(self, name: str) -> Heartbeat | None:
cursor = await self._conn.execute(
f"SELECT {_COLUMNS} FROM heartbeats WHERE name = ?", # noqa: S608
(name,),
)
row = await cursor.fetchone()
return _row_to_heartbeat(row) if row else None
async def record(
self, name: str, *, status: HeartbeatStatus, text: str, period: int
) -> Heartbeat:
stamp = now().isoformat()
ok_at = stamp if status is HeartbeatStatus.OK else None
await self._conn.execute(
"INSERT INTO heartbeats "
"(name, status, text, period, created_at, last_ping_at, last_ok_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(name) DO UPDATE SET status = excluded.status, "
"text = excluded.text, period = excluded.period, "
"last_ping_at = excluded.last_ping_at, "
"last_ok_at = COALESCE(excluded.last_ok_at, last_ok_at), "
"is_overdue = CASE WHEN excluded.last_ok_at IS NULL "
"THEN is_overdue ELSE 0 END, "
"alerted_at = CASE WHEN excluded.last_ok_at IS NULL "
"THEN alerted_at ELSE NULL END",
(name, status.value, text, period, stamp, stamp, ok_at),
)
await self._conn.commit()
heartbeat = await self.by_name(name)
assert heartbeat is not None
return heartbeat
async def mark_overdue(self, heartbeat_id: int, last_ping_at: str) -> bool:
cursor = await self._conn.execute(
"UPDATE heartbeats SET is_overdue = 1, alerted_at = ? "
"WHERE id = ? AND last_ping_at = ?",
(now().isoformat(), heartbeat_id, last_ping_at),
)
await self._conn.commit()
return cursor.rowcount > 0
async def delete(self, heartbeat_id: int) -> None:
await self._conn.execute("DELETE FROM heartbeats WHERE id = ?", (heartbeat_id,))
await self._conn.commit()