The Monitors list now opens with a count strip (down, up with how many degraded, pending, paused) and sorts down, degraded, pending, up, paused, by name within each. Degraded is up but worth a look: uptime at or below its warning floor, or the latest response past its critical cutoff, both from Settings → Thresholds; a slow-but-tolerable reply is coloured, not sorted. A monitor had no page of its own: every link went to the list. Now /monitors/<id> shows its state and since when, latency, uptime for 24h/7d/ 30d, TLS days, the last checks, recent failures with their errors, and its settings (HTTP header names, never their values). Status links to it, and adding or editing returns to it. Adding is its own page (/monitors/new, form.html shared with edit) behind an "Add monitor" button; a refused add re-renders the form with the error and what was typed instead of a bare 400 text. Row actions are labelled icon buttons, type names read Ping/TCP/DNS/HTTP (|monitor_type), and the heartbeat uses pill-up/pill-down, so HEX_CEILING drops to 56. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
5.0 KiB
Python
120 lines
5.0 KiB
Python
from __future__ import annotations
|
|
import enum
|
|
import json
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from sqlalchemy import (
|
|
Boolean, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String, Text,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
from .base import Base
|
|
|
|
|
|
class MonitorType(str, enum.Enum):
|
|
"""The kinds of synthetic/uptime check a Monitor can run."""
|
|
icmp = "icmp" # ICMP echo via the system ping binary
|
|
tcp = "tcp" # TCP connect to target:port
|
|
dns = "dns" # DNS A/AAAA resolution, optional expected-IP assertion
|
|
http = "http" # HTTP(S) request — status / content / TLS expiry
|
|
|
|
|
|
# Whitelist for the CHECK constraint. `type` is a plain String + CHECK rather
|
|
# than a native Postgres enum so a new monitor type ships as a same-change
|
|
# DROP/ADD CONSTRAINT (family rule 36) instead of the heavier ALTER TYPE ADD
|
|
# VALUE a native enum would force.
|
|
MONITOR_TYPE_VALUES = tuple(t.value for t in MonitorType)
|
|
# How a page names each type.
|
|
MONITOR_TYPE_LABELS = {"icmp": "Ping", "tcp": "TCP", "dns": "DNS", "http": "HTTP"}
|
|
_TYPE_CHECK = "type IN ('icmp', 'tcp', 'dns', 'http')"
|
|
|
|
|
|
class Monitor(Base):
|
|
"""A single configured uptime/synthetic check of any type.
|
|
|
|
Unifies what used to be host-coupled ping/DNS facets (columns on `hosts`)
|
|
and the standalone HTTP plugin monitor. `host_id` is OPTIONAL: set when the
|
|
check belongs to a registered Host (then it surfaces on the host hub), or
|
|
NULL for a free-standing custom destination. Type-specific settings live in
|
|
`config_json` so adding a new type never widens this table.
|
|
"""
|
|
__tablename__ = "monitors"
|
|
|
|
id: Mapped[str] = mapped_column(
|
|
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
|
)
|
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
type: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
# Address (icmp/tcp/dns) or URL (http) — free-form, not tied to a Host row.
|
|
target: Mapped[str] = mapped_column(String(2048), nullable=False)
|
|
host_id: Mapped[str | None] = mapped_column(
|
|
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=True, index=True
|
|
)
|
|
# Type-specific config as a JSON object. Keys by type:
|
|
# icmp -> {}
|
|
# tcp -> {"port": int}
|
|
# dns -> {"expected_ip": str | None}
|
|
# http -> {"method", "expected_status", "content_match", "headers",
|
|
# "timeout_seconds", "follow_redirects", "verify_ssl"}
|
|
config_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
# 0 = use the global monitors.poll_interval_seconds setting.
|
|
check_interval_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
# Updated after each check so the scheduler can compute next-run efficiently.
|
|
last_checked_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False,
|
|
default=lambda: datetime.now(timezone.utc),
|
|
)
|
|
|
|
__table_args__ = (
|
|
CheckConstraint(_TYPE_CHECK, name="ck_monitors_type"),
|
|
)
|
|
|
|
@property
|
|
def config(self) -> dict:
|
|
try:
|
|
return json.loads(self.config_json or "{}")
|
|
except (ValueError, TypeError):
|
|
return {}
|
|
|
|
def set_config(self, cfg: dict) -> None:
|
|
self.config_json = json.dumps(cfg or {})
|
|
|
|
|
|
class MonitorResult(Base):
|
|
"""One check result for a Monitor, across all types.
|
|
|
|
Superset of the old per-type result columns: response_ms is universal,
|
|
while status_code/content_matched/tls_expires_at (http) and resolved_ip
|
|
(dns) are populated only for the types that produce them.
|
|
"""
|
|
__tablename__ = "monitor_results"
|
|
|
|
id: Mapped[str] = mapped_column(
|
|
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
|
)
|
|
monitor_id: Mapped[str] = mapped_column(
|
|
String(36), ForeignKey("monitors.id", ondelete="CASCADE"),
|
|
nullable=False, index=True,
|
|
)
|
|
checked_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False,
|
|
default=lambda: datetime.now(timezone.utc), index=True,
|
|
)
|
|
is_up: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
response_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True) # http
|
|
resolved_ip: Mapped[str | None] = mapped_column(String(255), nullable=True) # dns
|
|
content_matched: Mapped[bool | None] = mapped_column(Boolean, nullable=True) # http
|
|
tls_expires_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True # http
|
|
)
|
|
error_msg: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
|
|
|
__table_args__ = (
|
|
# The status/heartbeat queries page the latest N per monitor by time.
|
|
Index("ix_monitor_results_monitor_checked", "monitor_id", "checked_at"),
|
|
)
|