From d4cf36b200fb54db098dabda4736f77b52fe1cc0 Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Thu, 19 Mar 2026 07:16:05 -0500 Subject: [PATCH] feat(15-01): add site alert rules/events migration, models, schemas, and config - Create Alembic migration 035 with site_alert_rules and site_alert_events tables, RLS policies, and GRANT - Add SiteAlertRule/SiteAlertEvent ORM models with enums for rule_type, severity, state - Add Pydantic schemas for rule/event CRUD and signal history points - Add SIGNAL_DEGRADATION_THRESHOLD_DB, ALERT_EVALUATION_INTERVAL_SECONDS, TREND_DETECTION_INTERVAL_SECONDS to Settings Co-Authored-By: Claude Opus 4.6 (1M context) --- .planning/ROADMAP.md | 11 +- .../035_site_alert_rules_and_events.py | 228 ++++++++++++++++++ backend/app/config.py | 5 + backend/app/models/__init__.py | 3 + backend/app/models/site_alert.py | 168 +++++++++++++ backend/app/schemas/site_alert.py | 168 +++++++++++++ 6 files changed, 578 insertions(+), 5 deletions(-) create mode 100644 backend/alembic/versions/035_site_alert_rules_and_events.py create mode 100644 backend/app/models/site_alert.py create mode 100644 backend/app/schemas/site_alert.py diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index aa8597f..5003f6d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -117,18 +117,19 @@ Plans: 2. System detects and surfaces signal degradation trends (e.g., "signal dropped 8dB over 2 weeks") 3. Operator can create site-scoped alert rules (e.g., "alert when >20% of devices at this site go offline") 4. Operator can create sector-scoped alert rules (e.g., "alert when sector average signal drops below -75dBm") -**Plans**: TBD +**Plans:** 3 plans Plans: -- [ ] 15-01: TBD -- [ ] 15-02: TBD +- [ ] 15-01-PLAN.md — Backend data model, services, and REST API for site alert rules, alert events, and signal history +- [ ] 15-02-PLAN.md — Backend scheduled tasks (trend detection + alert evaluation) and frontend API clients +- [ ] 15-03-PLAN.md — Frontend signal history charts, alert rules UI, alert events table, and notification bell ## Coverage | Category | Requirements | Phase | Count | |----------|-------------|-------|-------| | Sites | SITE-01, SITE-02, SITE-03, SITE-04, SITE-05, SITE-06 | 11 | 3/3 | Complete | 2026-03-19 | DASH-01 | 11 | 1 | -| Site Dashboard | DASH-02, DASH-03, DASH-04 | 14 | 3/3 | Complete | 2026-03-19 | SECT-01, SECT-02, SECT-03 | 14 | 3 | +| Site Dashboard | DASH-02, DASH-03, DASH-04 | 14 | 3/3 | Complete | 2026-03-19 | SECT-01, SECT-02, SECT-03 | 14 | 3 | | Wireless Collection | WRCL-01, WRCL-02, WRCL-03, WRCL-04, WRCL-05, WRCL-06 | 12 | 2/2 | Complete | 2026-03-19 | LINK-01, LINK-02, LINK-03, LINK-04 | 13 | 3/3 | Complete | 2026-03-19 | WRUI-01, WRUI-02, WRUI-03 | 14 | 3 | | Signal Trending | TRND-01, TRND-02 | 15 | 2 | | Site Alerting | ALRT-01, ALRT-02 | 15 | 2 | @@ -145,7 +146,7 @@ Phases execute in numeric order: 11 -> 11.x -> 12 -> 12.x -> 13 -> 13.x -> 14 -> | 12. Per-Client Wireless Collection | 0/2 | Planning complete | - | | 13. Link Discovery + Registration Ingestion | 0/3 | Planning complete | - | | 14. Site Dashboard + Sector Views + Wireless UI | 0/3 | Planning complete | - | -| 15. Signal Trending + Site Alerting | 0/? | Not started | - | +| 15. Signal Trending + Site Alerting | 0/3 | Planning complete | - | --- *Roadmap created: 2026-03-18* diff --git a/backend/alembic/versions/035_site_alert_rules_and_events.py b/backend/alembic/versions/035_site_alert_rules_and_events.py new file mode 100644 index 0000000..1ddb2a7 --- /dev/null +++ b/backend/alembic/versions/035_site_alert_rules_and_events.py @@ -0,0 +1,228 @@ +"""Create site_alert_rules and site_alert_events tables with RLS. + +Revision ID: 035 +Revises: 034 +Create Date: 2026-03-19 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "035" +down_revision = "034" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Create site_alert_rules table + op.create_table( + "site_alert_rules", + sa.Column( + "id", + sa.dialects.postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "tenant_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "site_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("sites.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "sector_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("sectors.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("rule_type", sa.String(50), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("threshold_value", sa.Numeric, nullable=False), + sa.Column("threshold_unit", sa.String(20), nullable=False), + sa.Column( + "enabled", + sa.Boolean, + nullable=False, + server_default=sa.text("true"), + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + + op.create_index( + "ix_site_alert_rules_tenant_site", + "site_alert_rules", + ["tenant_id", "site_id"], + ) + op.create_index( + "ix_site_alert_rules_tenant_site_sector", + "site_alert_rules", + ["tenant_id", "site_id", "sector_id"], + postgresql_where=sa.text("sector_id IS NOT NULL"), + ) + + # 2. Create site_alert_events table + op.create_table( + "site_alert_events", + sa.Column( + "id", + sa.dialects.postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "tenant_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "site_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("sites.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "sector_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("sectors.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "rule_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("site_alert_rules.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "device_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("devices.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "link_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("wireless_links.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "severity", + sa.String(20), + nullable=False, + server_default=sa.text("'warning'"), + ), + sa.Column("message", sa.Text, nullable=False), + sa.Column( + "state", + sa.String(20), + nullable=False, + server_default=sa.text("'active'"), + ), + sa.Column( + "consecutive_hits", + sa.Integer, + nullable=False, + server_default=sa.text("1"), + ), + sa.Column( + "triggered_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "resolved_by", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + ) + + op.create_index( + "ix_site_alert_events_tenant_site_state", + "site_alert_events", + ["tenant_id", "site_id", "state"], + ) + + # 3. Enable RLS on both tables + conn = op.get_bind() + + # site_alert_rules RLS + conn.execute(sa.text("ALTER TABLE site_alert_rules ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE site_alert_rules FORCE ROW LEVEL SECURITY")) + conn.execute( + sa.text(""" + CREATE POLICY tenant_isolation ON site_alert_rules + USING ( + tenant_id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ) + WITH CHECK ( + tenant_id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ) + """) + ) + conn.execute( + sa.text( + "GRANT SELECT, INSERT, UPDATE, DELETE ON site_alert_rules TO app_user" + ) + ) + + # site_alert_events RLS + conn.execute(sa.text("ALTER TABLE site_alert_events ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE site_alert_events FORCE ROW LEVEL SECURITY")) + conn.execute( + sa.text(""" + CREATE POLICY tenant_isolation ON site_alert_events + USING ( + tenant_id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ) + WITH CHECK ( + tenant_id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ) + """) + ) + conn.execute( + sa.text( + "GRANT SELECT, INSERT, UPDATE, DELETE ON site_alert_events TO app_user" + ) + ) + + +def downgrade() -> None: + conn = op.get_bind() + + # Drop RLS policies + conn.execute( + sa.text("DROP POLICY IF EXISTS tenant_isolation ON site_alert_events") + ) + conn.execute( + sa.text("DROP POLICY IF EXISTS tenant_isolation ON site_alert_rules") + ) + + # Drop tables (indexes drop automatically with tables) + op.drop_table("site_alert_events") + op.drop_table("site_alert_rules") diff --git a/backend/app/config.py b/backend/app/config.py index 74e4e7a..0baa30f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -126,6 +126,11 @@ class Settings(BaseSettings): PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: int = 30 APP_BASE_URL: str = "http://localhost:3000" + # Signal trending and site alerting (Phase 15) + SIGNAL_DEGRADATION_THRESHOLD_DB: int = 5 + ALERT_EVALUATION_INTERVAL_SECONDS: int = 300 + TREND_DETECTION_INTERVAL_SECONDS: int = 3600 + # Retention cleanup — delete config snapshots older than N days CONFIG_RETENTION_DAYS: int = 90 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 565cb64..ab8ac7f 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -21,6 +21,7 @@ from app.models.api_key import ApiKey from app.models.config_backup import RouterConfigSnapshot, RouterConfigDiff, RouterConfigChange from app.models.device_interface import DeviceInterface from app.models.wireless_link import WirelessLink, LinkState +from app.models.site_alert import SiteAlertRule, SiteAlertEvent __all__ = [ "Tenant", @@ -52,4 +53,6 @@ __all__ = [ "DeviceInterface", "WirelessLink", "LinkState", + "SiteAlertRule", + "SiteAlertEvent", ] diff --git a/backend/app/models/site_alert.py b/backend/app/models/site_alert.py new file mode 100644 index 0000000..ab6dc55 --- /dev/null +++ b/backend/app/models/site_alert.py @@ -0,0 +1,168 @@ +"""Site alert system ORM models: site/sector-scoped alert rules and events. + +Separate from the device-level alert system in alert.py. These models support +site-wide and sector-scoped alerting for Phase 15 (signal trending, site alerting). +""" + +import uuid +from datetime import datetime +from enum import Enum + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base + + +class SiteAlertRuleType(str, Enum): + """Types of site/sector alert rules.""" + + DEVICE_OFFLINE_PERCENT = "device_offline_percent" + DEVICE_OFFLINE_COUNT = "device_offline_count" + SECTOR_SIGNAL_AVG = "sector_signal_avg" + SECTOR_CLIENT_DROP = "sector_client_drop" + SIGNAL_DEGRADATION = "signal_degradation" + + +class AlertSeverity(str, Enum): + """Alert severity levels.""" + + WARNING = "warning" + CRITICAL = "critical" + + +class AlertState(str, Enum): + """Alert event states.""" + + ACTIVE = "active" + RESOLVED = "resolved" + + +class SiteAlertRule(Base): + """Configurable site/sector-scoped alert threshold rule. + + Rules are always scoped to a site, and optionally to a specific sector. + When conditions are met, site_alert_events are created by the evaluation task. + """ + + __tablename__ = "site_alert_rules" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ) + site_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("sites.id", ondelete="CASCADE"), + nullable=False, + ) + sector_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("sectors.id", ondelete="SET NULL"), + nullable=True, + ) + rule_type: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + threshold_value: Mapped[float] = mapped_column(Numeric, nullable=False) + threshold_unit: Mapped[str] = mapped_column(String(20), nullable=False) + enabled: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default="true" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + # Relationships + site: Mapped["Site"] = relationship("Site") # type: ignore[name-defined] + sector: Mapped["Sector | None"] = relationship("Sector") # type: ignore[name-defined] + + def __repr__(self) -> str: + return f"" + + +class SiteAlertEvent(Base): + """Record of a site/sector alert firing or being resolved. + + Created by the scheduled alert evaluation task (Plan 02). + Resolved manually by operators via the API. + """ + + __tablename__ = "site_alert_events" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ) + site_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("sites.id", ondelete="CASCADE"), + nullable=False, + ) + sector_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("sectors.id", ondelete="SET NULL"), + nullable=True, + ) + rule_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("site_alert_rules.id", ondelete="SET NULL"), + nullable=True, + ) + device_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="SET NULL"), + nullable=True, + ) + link_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("wireless_links.id", ondelete="SET NULL"), + nullable=True, + ) + severity: Mapped[str] = mapped_column( + String(20), nullable=False, default="warning", server_default="warning" + ) + message: Mapped[str] = mapped_column(Text, nullable=False) + state: Mapped[str] = mapped_column( + String(20), nullable=False, default="active", server_default="active" + ) + consecutive_hits: Mapped[int] = mapped_column( + Integer, nullable=False, default=1, server_default="1" + ) + triggered_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + resolved_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + resolved_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + + # Relationships + site: Mapped["Site"] = relationship("Site") # type: ignore[name-defined] + sector: Mapped["Sector | None"] = relationship("Sector") # type: ignore[name-defined] + rule: Mapped["SiteAlertRule | None"] = relationship("SiteAlertRule") + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/schemas/site_alert.py b/backend/app/schemas/site_alert.py new file mode 100644 index 0000000..7e5c4eb --- /dev/null +++ b/backend/app/schemas/site_alert.py @@ -0,0 +1,168 @@ +"""Pydantic schemas for site alert rules, events, and signal history.""" + +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, field_validator + + +class SiteAlertRuleCreate(BaseModel): + """Schema for creating a new site alert rule.""" + + name: str + rule_type: str + threshold_value: float + threshold_unit: str + sector_id: Optional[uuid.UUID] = None + description: Optional[str] = None + enabled: bool = True + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + v = v.strip() + if len(v) < 1 or len(v) > 255: + raise ValueError("Rule name must be 1-255 characters") + return v + + @field_validator("rule_type") + @classmethod + def validate_rule_type(cls, v: str) -> str: + allowed = { + "device_offline_percent", + "device_offline_count", + "sector_signal_avg", + "sector_client_drop", + "signal_degradation", + } + if v not in allowed: + raise ValueError(f"rule_type must be one of: {', '.join(sorted(allowed))}") + return v + + @field_validator("threshold_unit") + @classmethod + def validate_threshold_unit(cls, v: str) -> str: + allowed = {"percent", "count", "dBm"} + if v not in allowed: + raise ValueError(f"threshold_unit must be one of: {', '.join(sorted(allowed))}") + return v + + +class SiteAlertRuleUpdate(BaseModel): + """Schema for updating an existing site alert rule. All fields optional.""" + + name: Optional[str] = None + rule_type: Optional[str] = None + threshold_value: Optional[float] = None + threshold_unit: Optional[str] = None + sector_id: Optional[uuid.UUID] = None + description: Optional[str] = None + enabled: Optional[bool] = None + + @field_validator("name") + @classmethod + def validate_name(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + v = v.strip() + if len(v) < 1 or len(v) > 255: + raise ValueError("Rule name must be 1-255 characters") + return v + + @field_validator("rule_type") + @classmethod + def validate_rule_type(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + allowed = { + "device_offline_percent", + "device_offline_count", + "sector_signal_avg", + "sector_client_drop", + "signal_degradation", + } + if v not in allowed: + raise ValueError(f"rule_type must be one of: {', '.join(sorted(allowed))}") + return v + + @field_validator("threshold_unit") + @classmethod + def validate_threshold_unit(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + allowed = {"percent", "count", "dBm"} + if v not in allowed: + raise ValueError(f"threshold_unit must be one of: {', '.join(sorted(allowed))}") + return v + + +class SiteAlertRuleResponse(BaseModel): + """Site alert rule response schema.""" + + id: uuid.UUID + tenant_id: uuid.UUID + site_id: uuid.UUID + sector_id: Optional[uuid.UUID] = None + rule_type: str + name: str + description: Optional[str] = None + threshold_value: float + threshold_unit: str + enabled: bool + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class SiteAlertRuleListResponse(BaseModel): + """List of site alert rules with total count.""" + + items: list[SiteAlertRuleResponse] + total: int + + +class SiteAlertEventResponse(BaseModel): + """Site alert event response schema.""" + + id: uuid.UUID + tenant_id: uuid.UUID + site_id: uuid.UUID + sector_id: Optional[uuid.UUID] = None + rule_id: Optional[uuid.UUID] = None + device_id: Optional[uuid.UUID] = None + link_id: Optional[uuid.UUID] = None + severity: str + message: str + state: str + consecutive_hits: int + triggered_at: datetime + resolved_at: Optional[datetime] = None + resolved_by: Optional[uuid.UUID] = None + + model_config = ConfigDict(from_attributes=True) + + +class SiteAlertEventListResponse(BaseModel): + """List of site alert events with total count.""" + + items: list[SiteAlertEventResponse] + total: int + + +class SignalHistoryPoint(BaseModel): + """A single time-bucketed signal history data point.""" + + timestamp: datetime + signal_avg: int + signal_min: int + signal_max: int + + +class SignalHistoryResponse(BaseModel): + """Signal history response with time-bucketed data points.""" + + items: list[SignalHistoryPoint] + mac_address: str + range: str