"""Unify ping/dns/http into one Monitor entity + monitor_results Merges the standalone "http" branch (http_001_initial) back into the core line and collapses the three former check models into `monitors` + `monitor_results`: * Host ping/dns facets (hosts.ping_enabled/dns_enabled/probe_type/... ) become Monitor rows linked via host_id. * http_monitors rows become hostless Monitor rows (type=http). * ping_results / dns_results / http_results history is copied into monitor_results, then the old tables + host columns are dropped. The http_monitors/http_results tables have no ORM models anymore (the http plugin was folded into core), so they're read/dropped via raw SQL. Revision ID: 0022_unify_monitors Revises: 0021_dashboard_widget_grid, http_001_initial Create Date: 2026-06-18 """ import json import uuid from typing import Sequence, Union from alembic import op import sqlalchemy as sa revision: str = "0022_unify_monitors" down_revision: Union[str, Sequence[str], None] = ( "0021_dashboard_widget_grid", "http_001_initial", ) branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def _new_id() -> str: return str(uuid.uuid4()) def upgrade() -> None: conn = op.get_bind() # ── 1. New tables ───────────────────────────────────────────────────────── op.create_table( "monitors", sa.Column("id", sa.String(36), primary_key=True), sa.Column("name", sa.String(128), nullable=False), sa.Column("type", sa.String(16), nullable=False), sa.Column("target", sa.String(2048), nullable=False), sa.Column("host_id", sa.String(36), sa.ForeignKey("hosts.id", ondelete="CASCADE"), nullable=True), sa.Column("config_json", sa.Text, nullable=False, server_default="{}"), sa.Column("enabled", sa.Boolean, nullable=False, server_default="true"), sa.Column("check_interval_seconds", sa.Integer, nullable=False, server_default="0"), sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), # rule 36: new monitor types ship via DROP/ADD of this CHECK constraint. sa.CheckConstraint("type IN ('icmp', 'tcp', 'dns', 'http')", name="ck_monitors_type"), ) op.create_index("ix_monitors_host_id", "monitors", ["host_id"]) op.create_table( "monitor_results", sa.Column("id", sa.String(36), primary_key=True), sa.Column("monitor_id", sa.String(36), sa.ForeignKey("monitors.id", ondelete="CASCADE"), nullable=False), sa.Column("checked_at", sa.DateTime(timezone=True), nullable=False), sa.Column("is_up", sa.Boolean, nullable=False, server_default="false"), sa.Column("response_ms", sa.Float, nullable=True), sa.Column("status_code", sa.Integer, nullable=True), sa.Column("resolved_ip", sa.String(255), nullable=True), sa.Column("content_matched", sa.Boolean, nullable=True), sa.Column("tls_expires_at", sa.DateTime(timezone=True), nullable=True), sa.Column("error_msg", sa.String(512), nullable=True), ) op.create_index("ix_monitor_results_monitor_id", "monitor_results", ["monitor_id"]) op.create_index("ix_monitor_results_checked_at", "monitor_results", ["checked_at"]) op.create_index("ix_monitor_results_monitor_checked", "monitor_results", ["monitor_id", "checked_at"]) ins_monitor = sa.text( "INSERT INTO monitors (id, name, type, target, host_id, config_json, " "enabled, check_interval_seconds, last_checked_at, created_at) VALUES " "(:id, :name, :type, :target, :host_id, :cfg, :enabled, :interval, " ":last, :created)" ) # ── 2. Host ping/dns facets → Monitor rows ──────────────────────────────── ping_map: dict[str, str] = {} # host_id -> monitor_id dns_map: dict[str, str] = {} hosts = conn.execute(sa.text( "SELECT id, name, address, probe_type, probe_port, ping_enabled, " "dns_enabled, dns_expected_ip, created_at FROM hosts" )).mappings().all() for h in hosts: if h["ping_enabled"]: mid = _new_id() mtype = "icmp" if str(h["probe_type"]) == "icmp" else "tcp" cfg = {} if mtype == "icmp" else {"port": h["probe_port"] or 80} conn.execute(ins_monitor, { "id": mid, "name": h["name"], "type": mtype, "target": h["address"], "host_id": h["id"], "cfg": json.dumps(cfg), "enabled": True, "interval": 0, "last": None, "created": h["created_at"], }) ping_map[h["id"]] = mid if h["dns_enabled"]: mid = _new_id() cfg = {"expected_ip": h["dns_expected_ip"]} conn.execute(ins_monitor, { "id": mid, "name": f"{h['name']} (DNS)", "type": "dns", "target": h["address"], "host_id": h["id"], "cfg": json.dumps(cfg), "enabled": True, "interval": 0, "last": None, "created": h["created_at"], }) dns_map[h["id"]] = mid # ── 3. http_monitors rows → hostless Monitor rows (raw SQL: no ORM) ─────── http_map: dict[str, str] = {} # old http monitor id -> new monitor id http_rows = conn.execute(sa.text( "SELECT id, name, url, method, expected_status, content_match, " "headers_json, timeout_seconds, check_interval_seconds, follow_redirects, " "verify_ssl, enabled, last_checked_at, created_at FROM http_monitors" )).mappings().all() for m in http_rows: mid = _new_id() try: headers = json.loads(m["headers_json"] or "{}") except (ValueError, TypeError): headers = {} cfg = { "method": m["method"], "expected_status": m["expected_status"], "content_match": m["content_match"], "headers": headers, "timeout_seconds": m["timeout_seconds"], "follow_redirects": bool(m["follow_redirects"]), "verify_ssl": bool(m["verify_ssl"]), } conn.execute(ins_monitor, { "id": mid, "name": m["name"], "type": "http", "target": m["url"], "host_id": None, "cfg": json.dumps(cfg), "enabled": bool(m["enabled"]), "interval": m["check_interval_seconds"] or 0, "last": m["last_checked_at"], "created": m["created_at"], }) http_map[m["id"]] = mid # ── 4. Result history → monitor_results ─────────────────────────────────── for host_id, mid in ping_map.items(): conn.execute(sa.text( "INSERT INTO monitor_results (id, monitor_id, checked_at, is_up, response_ms) " "SELECT gen_random_uuid()::text, :mid, probed_at, (status = 'up'), response_time_ms " "FROM ping_results WHERE host_id = :hid" ), {"mid": mid, "hid": host_id}) for host_id, mid in dns_map.items(): conn.execute(sa.text( "INSERT INTO monitor_results (id, monitor_id, checked_at, is_up, resolved_ip) " "SELECT gen_random_uuid()::text, :mid, resolved_at, (status = 'resolved'), resolved_ip " "FROM dns_results WHERE host_id = :hid" ), {"mid": mid, "hid": host_id}) for old_id, mid in http_map.items(): conn.execute(sa.text( "INSERT INTO monitor_results (id, monitor_id, checked_at, is_up, response_ms, " "status_code, content_matched, tls_expires_at, error_msg) " "SELECT gen_random_uuid()::text, :mid, checked_at, is_up, response_ms, " "status_code, content_matched, tls_expires_at, error_msg " "FROM http_results WHERE monitor_id = :oid" ), {"mid": mid, "oid": old_id}) # ── 4b. Repoint dashboard widgets (ping/dns/http_monitors → monitors) ───── # The ping/dns/http_monitors widget keys are retired; one `monitors` widget # replaces them. Convert ping in place, drop the now-redundant others. conn.execute(sa.text( "UPDATE dashboard_widgets SET widget_key = 'monitors' WHERE widget_key = 'ping'" )) conn.execute(sa.text( "DELETE FROM dashboard_widgets WHERE widget_key IN ('dns', 'http_monitors')" )) # ── 5. Drop the old tables, host columns, and orphaned enum types ───────── op.drop_table("ping_results") op.drop_table("dns_results") op.drop_table("http_results") op.drop_table("http_monitors") op.drop_column("hosts", "ping_enabled") op.drop_column("hosts", "dns_enabled") op.drop_column("hosts", "dns_expected_ip") op.drop_column("hosts", "probe_type") op.drop_column("hosts", "probe_port") op.drop_column("hosts", "poll_interval_seconds") op.execute("DROP TYPE IF EXISTS pingstatus") op.execute("DROP TYPE IF EXISTS dnsstatus") op.execute("DROP TYPE IF EXISTS probetype") def downgrade() -> None: # Dev-only project (no installs to protect): downgrade restores the table # shells so the schema is walkable, but does NOT recover migrated history. op.add_column("hosts", sa.Column("poll_interval_seconds", sa.Integer, nullable=True)) op.add_column("hosts", sa.Column("probe_port", sa.Integer, nullable=False, server_default="80")) op.add_column("hosts", sa.Column("probe_type", sa.Enum("tcp", "icmp", name="probetype"), nullable=False, server_default="tcp")) op.add_column("hosts", sa.Column("dns_expected_ip", sa.String(255), nullable=True)) op.add_column("hosts", sa.Column("dns_enabled", sa.Boolean, nullable=False, server_default="false")) op.add_column("hosts", sa.Column("ping_enabled", sa.Boolean, nullable=False, server_default="true")) op.create_table( "ping_results", sa.Column("id", sa.String(36), primary_key=True), sa.Column("host_id", sa.String(36), sa.ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False), sa.Column("probed_at", sa.DateTime(timezone=True), nullable=False), sa.Column("status", sa.Enum("up", "down", name="pingstatus"), nullable=False), sa.Column("response_time_ms", sa.Float, nullable=True), ) op.create_table( "dns_results", sa.Column("id", sa.String(36), primary_key=True), sa.Column("host_id", sa.String(36), sa.ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False), sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=False), sa.Column("status", sa.Enum("resolved", "failed", name="dnsstatus"), nullable=False), sa.Column("resolved_ip", sa.String(255), nullable=True), ) op.create_table( "http_monitors", sa.Column("id", sa.String(36), primary_key=True), sa.Column("name", sa.String(128), nullable=False), sa.Column("url", sa.String(2048), nullable=False), sa.Column("method", sa.String(8), nullable=False, server_default="GET"), sa.Column("expected_status", sa.Integer, nullable=False, server_default="200"), sa.Column("content_match", sa.String(512), nullable=False, server_default=""), sa.Column("headers_json", sa.Text, nullable=False, server_default="{}"), sa.Column("timeout_seconds", sa.Integer, nullable=False, server_default="10"), sa.Column("check_interval_seconds", sa.Integer, nullable=False, server_default="0"), sa.Column("follow_redirects", sa.Boolean, nullable=False, server_default="1"), sa.Column("verify_ssl", sa.Boolean, nullable=False, server_default="1"), sa.Column("enabled", sa.Boolean, nullable=False, server_default="1"), sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), ) op.create_table( "http_results", sa.Column("id", sa.String(36), primary_key=True), sa.Column("monitor_id", sa.String(36), nullable=False, index=True), sa.Column("checked_at", sa.DateTime(timezone=True), nullable=False, index=True), sa.Column("status_code", sa.Integer, nullable=True), sa.Column("response_ms", sa.Float, nullable=True), sa.Column("is_up", sa.Boolean, nullable=False, server_default="0"), sa.Column("content_matched", sa.Boolean, nullable=True), sa.Column("error_msg", sa.String(512), nullable=True), sa.Column("tls_expires_at", sa.DateTime(timezone=True), nullable=True), ) op.drop_index("ix_monitor_results_monitor_checked", table_name="monitor_results") op.drop_index("ix_monitor_results_checked_at", table_name="monitor_results") op.drop_index("ix_monitor_results_monitor_id", table_name="monitor_results") op.drop_table("monitor_results") op.drop_index("ix_monitors_host_id", table_name="monitors") op.drop_table("monitors")