refactor(notifications): self-contained rules, remove abstract channels

- Each notification rule contains its own delivery config inline
- No more separate channels with abstract IDs to cross-reference
- Delivery type selector (Mesh Broadcast/DM/Email/Webhook) with
  inline config fields per type
- Follows MeshMonitor trigger-action UX pattern
- Channel picker from radio for mesh broadcast
- Node picker for mesh DM
- Collapsed rule cards show readable one-line summary
- Trigger type: condition (alerts) or schedule (daily reports)
- Schedule triggers support daily, weekly, custom cron
- Message types: mesh health, RF propagation, alerts digest, custom
- Migrates old channels+rules config to new flat format on load

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-13 07:31:59 +00:00
commit b4f7e24c26
9 changed files with 1248 additions and 1095 deletions

View file

@ -425,14 +425,38 @@ class EnvironmentalConfig:
@dataclass
class NotificationChannelConfig:
"""Configuration for a notification channel."""
class NotificationRuleConfig:
"""Self-contained notification rule with inline delivery config."""
id: str = ""
type: str = ""
name: str = ""
enabled: bool = True
channel_index: int = 0
# Trigger type
trigger_type: str = "condition" # "condition" or "schedule"
# Condition trigger fields
categories: list = field(default_factory=list) # Empty = all categories
min_severity: str = "warning"
# Schedule trigger fields
schedule_frequency: str = "daily" # daily, twice_daily, weekly, custom
schedule_time: str = "07:00"
schedule_time_2: str = "19:00" # For twice_daily
schedule_days: list = field(default_factory=list) # For weekly
schedule_cron: str = "" # For custom
message_type: str = "mesh_health_summary"
custom_message: str = ""
# Delivery type
delivery_type: str = "mesh_broadcast" # mesh_broadcast, mesh_dm, email, webhook
# Mesh broadcast fields
broadcast_channel: int = 0
# Mesh DM fields
node_ids: list = field(default_factory=list)
# Email fields
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
@ -440,20 +464,18 @@ class NotificationChannelConfig:
smtp_tls: bool = True
from_address: str = ""
recipients: list = field(default_factory=list)
url: str = ""
headers: dict = field(default_factory=dict)
# Webhook fields
webhook_url: str = ""
webhook_headers: dict = field(default_factory=dict)
@dataclass
class NotificationRuleConfig:
"""Configuration for a notification rule."""
name: str = ""
categories: list = field(default_factory=list)
min_severity: str = "warning"
channel_ids: list = field(default_factory=list)
# Behavior
cooldown_minutes: int = 10
override_quiet: bool = False
# Legacy field for migration (ignored in new format)
channel_ids: list = field(default_factory=list)
@dataclass
class NotificationsConfig:
@ -462,9 +484,7 @@ class NotificationsConfig:
enabled: bool = False
quiet_hours_start: str = "22:00"
quiet_hours_end: str = "06:00"
dedup_seconds: int = 600
channels: list = field(default_factory=list)
rules: list = field(default_factory=list)
rules: list = field(default_factory=list) # List of NotificationRuleConfig
@dataclass
class DashboardConfig:
@ -515,6 +535,69 @@ class Config:
return ""
def _migrate_legacy_channels(notifications, data: dict):
"""Migrate legacy channels+rules format to self-contained rules."""
old_channels = data.get("channels", [])
old_rules = data.get("rules", [])
if not old_channels:
return
_config_logger.info("Migrating %d legacy notification channels to inline rules", len(old_channels))
# Build channel lookup
channel_map = {}
for ch in old_channels:
if isinstance(ch, dict):
channel_map[ch.get("id", "")] = ch
# Convert each old rule + referenced channels to new format
migrated_rules = []
for old_rule in old_rules:
if not isinstance(old_rule, dict):
continue
channel_ids = old_rule.get("channel_ids", [])
if not channel_ids:
continue
for ch_id in channel_ids:
ch = channel_map.get(ch_id)
if not ch:
continue
# Create new rule with inline delivery config
new_rule = NotificationRuleConfig(
name=old_rule.get("name", "") or ch_id,
enabled=ch.get("enabled", True),
trigger_type="condition",
categories=old_rule.get("categories", []),
min_severity=old_rule.get("min_severity", "warning"),
delivery_type=ch.get("type", "mesh_broadcast"),
broadcast_channel=ch.get("channel_index", 0),
node_ids=ch.get("node_ids", []),
smtp_host=ch.get("smtp_host", ""),
smtp_port=ch.get("smtp_port", 587),
smtp_user=ch.get("smtp_user", ""),
smtp_password=ch.get("smtp_password", ""),
smtp_tls=ch.get("smtp_tls", True),
from_address=ch.get("from_address", ""),
recipients=ch.get("recipients", []),
webhook_url=ch.get("url", ""),
webhook_headers=ch.get("headers", {}),
cooldown_minutes=10,
override_quiet=old_rule.get("override_quiet", False),
)
migrated_rules.append(new_rule)
# Replace rules with migrated ones (migrated rules come first, then any new-format rules)
if migrated_rules:
# Keep only non-migrated rules (those without channel_ids)
existing_new_rules = [r for r in notifications.rules if not getattr(r, 'channel_ids', [])]
notifications.rules = migrated_rules + existing_new_rules
_config_logger.info("Migrated to %d self-contained rules", len(notifications.rules))
def _dict_to_dataclass(cls, data: dict):
"""Recursively convert dict to dataclass, handling nested structures."""
if data is None:
@ -574,10 +657,11 @@ def _dict_to_dataclass(cls, data: dict):
kwargs[key] = _dict_to_dataclass(DashboardConfig, value)
elif key == "notifications" and isinstance(value, dict):
notifications = _dict_to_dataclass(NotificationsConfig, value)
if "channels" in value and isinstance(value["channels"], list):
notifications.channels = [_dict_to_dataclass(NotificationChannelConfig, c) if isinstance(c, dict) else c for c in value["channels"]]
if "rules" in value and isinstance(value["rules"], list):
notifications.rules = [_dict_to_dataclass(NotificationRuleConfig, r) if isinstance(r, dict) else r for r in value["rules"]]
# Migrate old channels+rules format if present
if "channels" in value and isinstance(value["channels"], list) and value["channels"]:
_migrate_legacy_channels(notifications, value)
kwargs[key] = notifications
else:
kwargs[key] = value