feat(*): komodo actions menu with pins and commands, fleet by server, uptime history
This commit is contained in:
1 parent
f8169810dc
commit
a471b1dfe2
35 files changed
+4458
-846
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()]
|
||||
@@ -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()
|
||||
@@ -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 = ?",
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user