feat(*): komodo actions menu with pins and commands, fleet by server, uptime history

This commit is contained in:
hh committed 2026-10-12 22:44:25 +02:00
1 parent f8169810dc
commit a471b1dfe2
35 files changed
+4458 -846

No files matched your search

+79 -2
View File
@@ -1,10 +1,20 @@
import shutil
from pathlib import Path
import aiosqlite
from utils.env import env
from utils.logging import logger
from .repositories import IncidentRepository, ServiceRepository
from .repositories import (
IncidentRepository,
PinRepository,
ServiceRepository,
SettingRepository,
StatRepository,
)
SCHEMA_VERSION = 2
SCHEMA = """
CREATE TABLE IF NOT EXISTS services (
@@ -30,6 +40,33 @@ CREATE TABLE IF NOT EXISTS incidents (
);
CREATE INDEX IF NOT EXISTS idx_incidents_service ON incidents(service_id);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS pins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
target_id TEXT NOT NULL,
name TEXT NOT NULL,
label TEXT NOT NULL,
mode TEXT NOT NULL,
args TEXT NOT NULL DEFAULT '{}',
position INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS hourly (
service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE,
hour INTEGER NOT NULL,
checks INTEGER NOT NULL DEFAULT 0,
fails INTEGER NOT NULL DEFAULT 0,
latency_sum INTEGER NOT NULL DEFAULT 0,
latency_n INTEGER NOT NULL DEFAULT 0,
latency_max INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (service_id, hour)
);
"""
MIGRATIONS: tuple[tuple[str, str, str], ...] = (
@@ -37,26 +74,66 @@ MIGRATIONS: tuple[tuple[str, str, str], ...] = (
("services", "is_paused", "INTEGER NOT NULL DEFAULT 0"),
("services", "last_latency_ms", "INTEGER"),
("services", "last_checked_at", "TEXT"),
("services", "needle", "TEXT"),
("services", "tls_expires_at", "TEXT"),
("services", "tls_warned", "INTEGER NOT NULL DEFAULT 0"),
("incidents", "muted", "INTEGER NOT NULL DEFAULT 0"),
)
def _prepare(path: str) -> None:
Path(path).parent.mkdir(parents=True, exist_ok=True)
def _has_data(path: str) -> bool:
source = Path(path)
return source.is_file() and source.stat().st_size > 0
def _copy(path: str, version: int) -> Path:
source = Path(path)
target = source.with_name(f"{source.name}.v{version}.bak")
shutil.copy2(source, target)
return target
class Database:
def __init__(self, path: str) -> None:
self._path = path
self._conn: aiosqlite.Connection | None = None
self.services: ServiceRepository = 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]
self.stats: StatRepository = None # type: ignore[assignment]
async def connect(self) -> None:
Path(self._path).parent.mkdir(parents=True, exist_ok=True)
_prepare(self._path)
await self._backup_before_migration()
self._conn = await aiosqlite.connect(self._path)
self._conn.row_factory = aiosqlite.Row
await self._conn.execute("PRAGMA foreign_keys = ON")
await self._conn.executescript(SCHEMA)
await self._migrate()
await self._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
await self._conn.commit()
self.services = ServiceRepository(self._conn)
self.incidents = IncidentRepository(self._conn)
self.pins = PinRepository(self._conn)
self.settings = SettingRepository(self._conn)
self.stats = StatRepository(self._conn)
await self.settings.load()
async def _backup_before_migration(self) -> None:
if not _has_data(self._path):
return
async with aiosqlite.connect(self._path) as conn:
cursor = await conn.execute("PRAGMA user_version")
row = await cursor.fetchone()
version = row[0] if row else 0
if version < SCHEMA_VERSION:
target = _copy(self._path, version)
logger.info(f"Database copied to {target} before schema {SCHEMA_VERSION}")
async def _migrate(self) -> None:
assert self._conn is not None
+47 -4
View File
@@ -1,11 +1,18 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import StrEnum
from html import escape
class MatchType(StrEnum):
STATUS = "status"
BODY = "body"
JSON = "json"
CONTAINS = "contains"
class PinMode(StrEnum):
INSTANT = "instant"
FORM = "form"
@dataclass(slots=True)
@@ -24,14 +31,23 @@ class Service:
is_paused: bool = False
last_latency_ms: int | None = None
last_checked_at: str | None = None
needle: str | None = None
tls_expires_at: str | None = None
tls_warned: int = 0
@property
def rule(self) -> str:
if self.match_type is MatchType.STATUS:
return f"HTTP-статус = {self.ok_status}"
return f"HTTP-статус {self.ok_status}"
if self.match_type is MatchType.JSON:
return f"<code>{self.json_path}</code> = <code>{self.json_value}</code>"
return "тело ответа неизменно"
path = escape(self.json_path or "", quote=False)
value = escape(self.json_value or "", quote=False)
return f"<code>{path}</code> = <code>{value}</code>"
if self.match_type is MatchType.CONTAINS:
return (
f"в ответе есть <code>{escape(self.needle or '', quote=False)}</code>"
)
return "тело ответа не меняется"
@property
def mark(self) -> str:
@@ -48,3 +64,30 @@ class Incident:
ended_at: str | None
reason: str
last_reminder_at: str
muted: bool = False
@dataclass(slots=True)
class Pin:
id: int
kind: str
target_id: str
name: str
label: str
mode: PinMode
args: dict = field(default_factory=dict)
position: int = 0
@dataclass(slots=True)
class Hour:
hour: int
checks: int
fails: int
latency_sum: int
latency_n: int
latency_max: int
@property
def latency(self) -> int | None:
return self.latency_sum // self.latency_n if self.latency_n else None
+10 -1
View File
@@ -1,4 +1,13 @@
from .incident import IncidentRepository
from .pin import PinRepository
from .service import ServiceRepository
from .setting import SettingRepository
from .stat import StatRepository
__all__ = ["IncidentRepository", "ServiceRepository"]
__all__ = [
"IncidentRepository",
"PinRepository",
"ServiceRepository",
"SettingRepository",
"StatRepository",
]
+17 -2
View File
@@ -3,7 +3,7 @@ import aiosqlite
from utils.db.models import Incident
from utils.format import now
_COLUMNS = "id, service_id, started_at, ended_at, reason, last_reminder_at"
_COLUMNS = "id, service_id, started_at, ended_at, reason, last_reminder_at, muted"
def _row_to_incident(row: aiosqlite.Row) -> Incident:
@@ -14,6 +14,7 @@ def _row_to_incident(row: aiosqlite.Row) -> Incident:
ended_at=row["ended_at"],
reason=row["reason"],
last_reminder_at=row["last_reminder_at"],
muted=bool(row["muted"]),
)
@@ -29,7 +30,7 @@ class IncidentRepository:
(service_id, stamp, reason, stamp),
)
await self._conn.commit()
incident = await self.get(cursor.lastrowid) # type: ignore[arg-type]
incident = await self.get(cursor.lastrowid or 0)
assert incident is not None
return incident
@@ -65,6 +66,12 @@ class IncidentRepository:
)
await self._conn.commit()
async def mute(self, incident_id: int) -> None:
await self._conn.execute(
"UPDATE incidents SET muted = 1 WHERE id = ?", (incident_id,)
)
await self._conn.commit()
async def for_service(self, service_id: int) -> list[Incident]:
cursor = await self._conn.execute(
f"SELECT {_COLUMNS} FROM incidents " # noqa: S608
@@ -72,3 +79,11 @@ class IncidentRepository:
(service_id,),
)
return [_row_to_incident(row) for row in await cursor.fetchall()]
async def since(self, stamp: str) -> list[Incident]:
cursor = await self._conn.execute(
f"SELECT {_COLUMNS} FROM incidents " # noqa: S608
"WHERE ended_at IS NULL OR ended_at >= ? ORDER BY id",
(stamp,),
)
return [_row_to_incident(row) for row in await cursor.fetchall()]
+98
View File
@@ -0,0 +1,98 @@
import json
import aiosqlite
from utils.db.models import Pin, PinMode
_COLUMNS = "id, kind, target_id, name, label, mode, args, position"
def _row_to_pin(row: aiosqlite.Row) -> Pin:
return Pin(
id=row["id"],
kind=row["kind"],
target_id=row["target_id"],
name=row["name"],
label=row["label"],
mode=PinMode(row["mode"]),
args=json.loads(row["args"] or "{}"),
position=row["position"],
)
class PinRepository:
def __init__(self, conn: aiosqlite.Connection) -> None:
self._conn = conn
async def all(self) -> list[Pin]:
cursor = await self._conn.execute(
f"SELECT {_COLUMNS} FROM pins ORDER BY position, id" # noqa: S608
)
return [_row_to_pin(row) for row in await cursor.fetchall()]
async def get(self, pin_id: int) -> Pin | None:
cursor = await self._conn.execute(
f"SELECT {_COLUMNS} FROM pins WHERE id = ?", # noqa: S608
(pin_id,),
)
row = await cursor.fetchone()
return _row_to_pin(row) if row else None
async def add( # noqa: PLR0913
self,
*,
kind: str,
target_id: str,
name: str,
label: str,
mode: PinMode,
args: dict,
) -> Pin:
cursor = await self._conn.execute(
"INSERT INTO pins (kind, target_id, name, label, mode, args, position) "
"VALUES (?, ?, ?, ?, ?, ?, "
"(SELECT COALESCE(MAX(position), 0) + 1 FROM pins))",
(
kind,
target_id,
name,
label,
mode.value,
json.dumps(args, ensure_ascii=False),
),
)
await self._conn.commit()
pin = await self.get(cursor.lastrowid or 0)
assert pin is not None
return pin
async def rename(self, pin_id: int, label: str) -> None:
await self._conn.execute(
"UPDATE pins SET label = ? WHERE id = ?", (label, pin_id)
)
await self._conn.commit()
async def set_mode(self, pin_id: int, mode: PinMode) -> None:
await self._conn.execute(
"UPDATE pins SET mode = ? WHERE id = ?", (mode.value, pin_id)
)
await self._conn.commit()
async def move(self, pin_id: int, step: int) -> None:
pins = await self.all()
index = next((i for i, p in enumerate(pins) if p.id == pin_id), None)
if index is None:
return
target = index + step
if not 0 <= target < len(pins):
return
pins[index], pins[target] = pins[target], pins[index]
for position, pin in enumerate(pins, start=1):
await self._conn.execute(
"UPDATE pins SET position = ? WHERE id = ?", (position, pin.id)
)
await self._conn.commit()
async def delete(self, pin_id: int) -> None:
await self._conn.execute("DELETE FROM pins WHERE id = ?", (pin_id,))
await self._conn.commit()
+50 -4
View File
@@ -6,7 +6,8 @@ from utils.format import now
_COLUMNS = (
"id, name, url, match_type, ok_status, ok_body, "
"json_path, json_value, is_up, created_at, "
"fail_streak, is_paused, last_latency_ms, last_checked_at"
"fail_streak, is_paused, last_latency_ms, last_checked_at, "
"needle, tls_expires_at, tls_warned"
)
@@ -26,6 +27,9 @@ def _row_to_service(row: aiosqlite.Row) -> Service:
is_paused=bool(row["is_paused"]),
last_latency_ms=row["last_latency_ms"],
last_checked_at=row["last_checked_at"],
needle=row["needle"],
tls_expires_at=row["tls_expires_at"],
tls_warned=row["tls_warned"],
)
@@ -65,11 +69,12 @@ class ServiceRepository:
ok_body: str = "",
json_path: str | None = None,
json_value: str | None = None,
needle: str | None = None,
) -> Service:
cursor = await self._conn.execute(
"INSERT INTO services "
"(name, url, match_type, ok_status, ok_body, json_path, json_value, "
"is_up, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)",
"needle, is_up, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)",
(
name,
url,
@@ -78,14 +83,49 @@ class ServiceRepository:
ok_body,
json_path,
json_value,
needle,
now().isoformat(),
),
)
await self._conn.commit()
service = await self.get(cursor.lastrowid) # type: ignore[arg-type]
service = await self.get(cursor.lastrowid or 0)
assert service is not None
return service
async def set_rule( # noqa: PLR0913
self,
service_id: int,
*,
match_type: MatchType,
ok_status: int,
ok_body: str = "",
json_path: str | None = None,
json_value: str | None = None,
needle: str | None = None,
) -> None:
await self._conn.execute(
"UPDATE services SET match_type = ?, ok_status = ?, ok_body = ?, "
"json_path = ?, json_value = ?, needle = ?, fail_streak = 0 WHERE id = ?",
(
match_type.value,
ok_status,
ok_body,
json_path,
json_value,
needle,
service_id,
),
)
await self._conn.commit()
async def set_url(self, service_id: int, url: str) -> None:
await self._conn.execute(
"UPDATE services SET url = ?, fail_streak = 0, tls_expires_at = NULL, "
"tls_warned = 0 WHERE id = ?",
(url, service_id),
)
await self._conn.commit()
async def set_up(self, service_id: int, *, is_up: bool) -> None:
await self._conn.execute(
"UPDATE services SET is_up = ? WHERE id = ?", (int(is_up), service_id)
@@ -112,6 +152,13 @@ class ServiceRepository:
)
await self._conn.commit()
async def set_tls(self, service_id: int, expires_at: str, warned: int) -> None:
await self._conn.execute(
"UPDATE services SET tls_expires_at = ?, tls_warned = ? WHERE id = ?",
(expires_at, warned, service_id),
)
await self._conn.commit()
async def rename(self, service_id: int, name: str) -> None:
await self._conn.execute(
"UPDATE services SET name = ? WHERE id = ?", (name, service_id)
@@ -121,7 +168,6 @@ class ServiceRepository:
async def set_baseline(
self, service_id: int, *, ok_status: int, ok_body: str, json_value: str | None
) -> None:
"""Переснять эталон с текущего ответа."""
await self._conn.execute(
"UPDATE services SET ok_status = ?, ok_body = ?, json_value = ? "
"WHERE id = ?",
+38
View File
@@ -0,0 +1,38 @@
import json
from typing import Any
import aiosqlite
DEFAULTS: dict[str, Any] = {
"actions": True,
"commands": False,
"confirm": True,
"muted": ["ScheduleRun"],
"tz": "UTC",
"digest": None,
"digest_sent": "",
}
class SettingRepository:
def __init__(self, conn: aiosqlite.Connection) -> None:
self._conn = conn
self._cache: dict[str, Any] = {}
async def load(self) -> None:
cursor = await self._conn.execute("SELECT key, value FROM settings")
self._cache = {
row["key"]: json.loads(row["value"]) for row in await cursor.fetchall()
}
def get(self, key: str) -> Any: # noqa: ANN401
return self._cache.get(key, DEFAULTS[key])
async def set(self, key: str, value: Any) -> None: # noqa: ANN401
self._cache[key] = value
await self._conn.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, json.dumps(value, ensure_ascii=False)),
)
await self._conn.commit()
+50
View File
@@ -0,0 +1,50 @@
import aiosqlite
from utils.db.models import Hour
SECONDS_IN_HOUR = 3600
class StatRepository:
def __init__(self, conn: aiosqlite.Connection) -> None:
self._conn = conn
async def record(
self, service_id: int, hour: int, *, healthy: bool, latency_ms: int | None
) -> None:
measured = latency_ms is not None
latency = latency_ms or 0
await self._conn.execute(
"INSERT INTO hourly "
"(service_id, hour, checks, fails, latency_sum, latency_n, latency_max) "
"VALUES (?, ?, 1, ?, ?, ?, ?) "
"ON CONFLICT(service_id, hour) DO UPDATE SET "
"checks = checks + 1, fails = fails + excluded.fails, "
"latency_sum = latency_sum + excluded.latency_sum, "
"latency_n = latency_n + excluded.latency_n, "
"latency_max = MAX(latency_max, excluded.latency_max)",
(service_id, hour, int(not healthy), latency, int(measured), latency),
)
await self._conn.commit()
async def since(self, service_id: int, hour: int) -> dict[int, Hour]:
cursor = await self._conn.execute(
"SELECT hour, checks, fails, latency_sum, latency_n, latency_max "
"FROM hourly WHERE service_id = ? AND hour >= ? ORDER BY hour",
(service_id, hour),
)
return {
row["hour"]: Hour(
hour=row["hour"],
checks=row["checks"],
fails=row["fails"],
latency_sum=row["latency_sum"],
latency_n=row["latency_n"],
latency_max=row["latency_max"],
)
for row in await cursor.fetchall()
}
async def purge(self, before_hour: int) -> None:
await self._conn.execute("DELETE FROM hourly WHERE hour < ?", (before_hour,))
await self._conn.commit()