feat(heartbeats): ping endpoint with silence alerts, menu screen and digest block
This commit is contained in:
1 parent
6ef321dc9f
commit
69e7ad79ac
14 files changed
+473
-17
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user