feat(monitors): unify ping/dns/http into one Monitor entity + custom targets
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m17s
CI / publish (push) Successful in 1m10s

Collapse the three former check types into a single core `Monitor` entity
with one management surface (/monitors), one result table (monitor_results),
and a single scheduled task. Every type can now watch a free-standing custom
destination (optional host_id) — not just a registered Host.

- models: Monitor + MonitorResult replace PingResult/DnsResult; Host loses its
  ping/dns facet columns (now Monitor rows linked by host_id).
- checks: monitors/{ping,dns,http}.py pure probes + runner.run_monitor
  dispatcher; one monitor_check scheduler with a per-monitor due-filter.
- status: single monitor_status_source replaces the three sources.
- UI: /monitors blueprint (type-aware add/edit/list/widget); host hub shows a
  host's linked monitors + "add monitor for this host"; nav + widget registry
  + alert metric catalog rewired. http plugin folded into core and removed.
- migration 0022 merges the http branch, data-migrates host facets +
  http_monitors + all three result histories, drops the old tables/columns.

Resolves the per-host ping/dns auto-attach issue (#275): monitors are now
explicit, never auto-added to every host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
2026-06-18 08:56:13 -04:00
parent 591706bd39
commit 35f658b573
53 changed files with 1628 additions and 1839 deletions
+4 -4
View File
@@ -1,7 +1,7 @@
from .base import Base
from .users import User
from .hosts import Host, ProbeType
from .monitors import PingResult, DnsResult, PingStatus, DnsStatus
from .hosts import Host
from .monitors import Monitor, MonitorResult, MonitorType
from .metrics import PluginMetric
from .alerts import AlertRule, AlertState, AlertEvent, AlertOperator, AlertStateEnum
from .ansible import AnsibleRun, AnsibleRunStatus
@@ -12,8 +12,8 @@ from .dashboard import Dashboard, DashboardWidget, DashboardShareToken
__all__ = [
"Base", "User",
"Host", "ProbeType",
"PingResult", "DnsResult", "PingStatus", "DnsStatus",
"Host",
"Monitor", "MonitorResult", "MonitorType",
"PluginMetric",
"AlertRule", "AlertState", "AlertEvent", "AlertOperator", "AlertStateEnum",
"AnsibleRun", "AnsibleRunStatus",
+4 -13
View File
@@ -1,29 +1,20 @@
from __future__ import annotations
import enum
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Enum, Integer, String
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class ProbeType(str, enum.Enum):
tcp = "tcp"
icmp = "icmp"
class Host(Base):
"""A registered host. Reachability/DNS/HTTP checks are now separate Monitor
rows that link back via Monitor.host_id — a Host is just identity + address.
"""
__tablename__ = "hosts"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(128), nullable=False)
address: Mapped[str] = mapped_column(String(255), nullable=False)
probe_type: Mapped[ProbeType] = mapped_column(Enum(ProbeType), nullable=False, default=ProbeType.tcp)
probe_port: Mapped[int] = mapped_column(Integer, nullable=False, default=80)
ping_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
dns_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
dns_expected_ip: Mapped[str | None] = mapped_column(String(255), nullable=True)
poll_interval_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
+97 -25
View File
@@ -1,45 +1,117 @@
from __future__ import annotations
import enum
import json
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Enum, Float, ForeignKey, String
from sqlalchemy import (
Boolean, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String, Text,
)
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class PingStatus(str, enum.Enum):
up = "up"
down = "down"
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
class DnsStatus(str, enum.Enum):
resolved = "resolved"
failed = "failed"
# 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)
_TYPE_CHECK = "type IN ('icmp', 'tcp', 'dns', 'http')"
class PingResult(Base):
__tablename__ = "ping_results"
class Monitor(Base):
"""A single configured uptime/synthetic check of any type.
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
host_id: Mapped[str] = mapped_column(
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False
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())
)
probed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
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
)
status: Mapped[PingStatus] = mapped_column(Enum(PingStatus), nullable=False)
response_time_ms: Mapped[float | None] = mapped_column(Float, nullable=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 DnsResult(Base):
__tablename__ = "dns_results"
class MonitorResult(Base):
"""One check result for a Monitor, across all types.
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
host_id: Mapped[str] = mapped_column(
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False
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())
)
resolved_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
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"),
)
status: Mapped[DnsStatus] = mapped_column(Enum(DnsStatus), nullable=False)
resolved_ip: Mapped[str | None] = mapped_column(String(255), nullable=True)