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) _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"), )