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

@ -226,56 +226,67 @@ notifications:
enabled: false enabled: false
quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours
quiet_hours_end: "06:00" quiet_hours_end: "06:00"
dedup_seconds: 600 # Don't resend same alert within this window
# Notification channels # Notification rules - each rule is self-contained with its own delivery config
channels: rules:
# Mesh broadcast - posts to mesh channel # All emergencies -> mesh broadcast
- id: "mesh-channel" - name: "Emergency Broadcast"
type: mesh_broadcast
enabled: true enabled: true
channel_index: 0 # Mesh channel to broadcast on trigger_type: condition
categories: [] # Empty = all categories
min_severity: "emergency"
delivery_type: mesh_broadcast
broadcast_channel: 0
cooldown_minutes: 5
override_quiet: true # Send even during quiet hours
# Example: Email channel (uncomment to enable) # Example: Fire alerts -> email
# - id: "email-admin" # - name: "Fire Alerts Email"
# type: email # enabled: true
# trigger_type: condition
# categories: ["wildfire_proximity", "new_ignition"]
# min_severity: "advisory"
# delivery_type: email
# smtp_host: "smtp.gmail.com" # smtp_host: "smtp.gmail.com"
# smtp_port: 587 # smtp_port: 587
# smtp_user: "you@gmail.com" # smtp_user: "you@gmail.com"
# smtp_password: "${SMTP_PASSWORD}" # Use env var # smtp_password: "${SMTP_PASSWORD}"
# smtp_tls: true # smtp_tls: true
# from_address: "meshai@yourdomain.com" # from_address: "meshai@yourdomain.com"
# recipients: ["admin@yourdomain.com"] # recipients: ["admin@yourdomain.com"]
# cooldown_minutes: 30
# Example: Webhook (Discord, Slack, ntfy, Home Assistant) # Example: All warnings -> Discord webhook
# - id: "discord-alerts" # - name: "Discord Alerts"
# type: webhook # enabled: true
# url: "https://discord.com/api/webhooks/..." # trigger_type: condition
# categories: []
# - id: "ntfy-alerts"
# type: webhook
# url: "https://ntfy.sh/your-topic"
# Notification rules - match alerts to channels
rules:
# All emergencies -> mesh broadcast
- name: "emergencies"
categories: [] # Empty = all categories
min_severity: "emergency"
channel_ids: ["mesh-channel"]
override_quiet: true # Send even during quiet hours
# Example: Fire alerts at any severity
# - name: "fire-alerts"
# categories: ["wildfire_proximity", "new_ignition"]
# min_severity: "advisory"
# channel_ids: ["mesh-channel", "email-admin"]
# Example: Infrastructure alerts
# - name: "infra-alerts"
# categories: ["infra_offline", "critical_node_down", "battery_emergency"]
# min_severity: "warning" # min_severity: "warning"
# channel_ids: ["mesh-channel"] # delivery_type: webhook
# webhook_url: "https://discord.com/api/webhooks/..."
# cooldown_minutes: 10
# Example: Daily health report -> mesh broadcast
# - name: "Morning Briefing"
# enabled: true
# trigger_type: schedule
# schedule_frequency: daily
# schedule_time: "07:00"
# message_type: mesh_health_summary
# delivery_type: mesh_broadcast
# broadcast_channel: 0
# Example: Weekly digest -> email
# - name: "Weekly Digest"
# enabled: true
# trigger_type: schedule
# schedule_frequency: weekly
# schedule_days: ["monday"]
# schedule_time: "08:00"
# message_type: alerts_digest
# delivery_type: email
# smtp_host: "smtp.gmail.com"
# recipients: ["admin@example.com"]
# === WEB DASHBOARD === # === WEB DASHBOARD ===
dashboard: dashboard:

File diff suppressed because it is too large Load diff

View file

@ -425,14 +425,38 @@ class EnvironmentalConfig:
@dataclass @dataclass
class NotificationChannelConfig: class NotificationRuleConfig:
"""Configuration for a notification channel.""" """Self-contained notification rule with inline delivery config."""
id: str = "" name: str = ""
type: str = ""
enabled: bool = True 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) node_ids: list = field(default_factory=list)
# Email fields
smtp_host: str = "" smtp_host: str = ""
smtp_port: int = 587 smtp_port: int = 587
smtp_user: str = "" smtp_user: str = ""
@ -440,20 +464,18 @@ class NotificationChannelConfig:
smtp_tls: bool = True smtp_tls: bool = True
from_address: str = "" from_address: str = ""
recipients: list = field(default_factory=list) 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 # Behavior
class NotificationRuleConfig: cooldown_minutes: int = 10
"""Configuration for a notification rule."""
name: str = ""
categories: list = field(default_factory=list)
min_severity: str = "warning"
channel_ids: list = field(default_factory=list)
override_quiet: bool = False override_quiet: bool = False
# Legacy field for migration (ignored in new format)
channel_ids: list = field(default_factory=list)
@dataclass @dataclass
class NotificationsConfig: class NotificationsConfig:
@ -462,9 +484,7 @@ class NotificationsConfig:
enabled: bool = False enabled: bool = False
quiet_hours_start: str = "22:00" quiet_hours_start: str = "22:00"
quiet_hours_end: str = "06:00" quiet_hours_end: str = "06:00"
dedup_seconds: int = 600 rules: list = field(default_factory=list) # List of NotificationRuleConfig
channels: list = field(default_factory=list)
rules: list = field(default_factory=list)
@dataclass @dataclass
class DashboardConfig: class DashboardConfig:
@ -515,6 +535,69 @@ class Config:
return "" 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): def _dict_to_dataclass(cls, data: dict):
"""Recursively convert dict to dataclass, handling nested structures.""" """Recursively convert dict to dataclass, handling nested structures."""
if data is None: if data is None:
@ -574,10 +657,11 @@ def _dict_to_dataclass(cls, data: dict):
kwargs[key] = _dict_to_dataclass(DashboardConfig, value) kwargs[key] = _dict_to_dataclass(DashboardConfig, value)
elif key == "notifications" and isinstance(value, dict): elif key == "notifications" and isinstance(value, dict):
notifications = _dict_to_dataclass(NotificationsConfig, value) 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): 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"]] 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 kwargs[key] = notifications
else: else:
kwargs[key] = value kwargs[key] = value

View file

@ -1,45 +1,10 @@
"""Notification API routes.""" """Notification API routes."""
from fastapi import APIRouter, Request, HTTPException from fastapi import APIRouter, Request, HTTPException
from pydantic import BaseModel
from typing import Optional
router = APIRouter(prefix="/notifications", tags=["notifications"]) router = APIRouter(prefix="/notifications", tags=["notifications"])
class ChannelCreate(BaseModel):
"""Channel creation request."""
id: str
type: str
enabled: bool = True
channel_index: int = 0
node_ids: list[str] = []
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_tls: bool = True
from_address: str = ""
recipients: list[str] = []
url: str = ""
headers: dict = {}
class RuleCreate(BaseModel):
"""Rule creation request."""
name: str
categories: list[str] = []
min_severity: str = "warning"
channel_ids: list[str] = []
override_quiet: bool = False
class QuietHoursUpdate(BaseModel):
"""Quiet hours update request."""
start: str
end: str
@router.get("/categories") @router.get("/categories")
async def get_categories(): async def get_categories():
"""Get all alert categories with descriptions.""" """Get all alert categories with descriptions."""
@ -50,34 +15,6 @@ async def get_categories():
return [] return []
@router.get("/channels")
async def get_channels(request: Request):
"""Get configured notification channels."""
notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router:
return []
return notification_router.get_channels()
@router.post("/channels")
async def create_channel(request: Request, channel: ChannelCreate):
"""Create a new notification channel."""
# This would require runtime config modification
# For now, return not implemented
raise HTTPException(status_code=501, detail="Channel creation requires config file edit")
@router.post("/channels/{channel_id}/test")
async def test_channel(request: Request, channel_id: str):
"""Send a test alert to a channel."""
notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured")
success, message = await notification_router.test_channel(channel_id)
return {"success": success, "message": message}
@router.get("/rules") @router.get("/rules")
async def get_rules(request: Request): async def get_rules(request: Request):
"""Get configured notification rules.""" """Get configured notification rules."""
@ -87,27 +24,12 @@ async def get_rules(request: Request):
return notification_router.get_rules() return notification_router.get_rules()
@router.post("/rules") @router.post("/rules/{rule_index}/test")
async def create_rule(request: Request, rule: RuleCreate): async def test_rule(request: Request, rule_index: int):
"""Create a new notification rule.""" """Send a test alert through a specific rule."""
# This would require runtime config modification notification_router = getattr(request.app.state, "notification_router", None)
raise HTTPException(status_code=501, detail="Rule creation requires config file edit") if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured")
success, message = await notification_router.test_rule(rule_index)
@router.get("/quiet-hours") return {"success": success, "message": message}
async def get_quiet_hours(request: Request):
"""Get quiet hours configuration."""
config = getattr(request.app.state, "config", None)
if not config or not hasattr(config, "notifications"):
return {"start": "22:00", "end": "06:00"}
return {
"start": config.notifications.quiet_hours_start,
"end": config.notifications.quiet_hours_end,
}
@router.put("/quiet-hours")
async def update_quiet_hours(request: Request, quiet_hours: QuietHoursUpdate):
"""Update quiet hours configuration."""
# This would require runtime config modification
raise HTTPException(status_code=501, detail="Quiet hours update requires config file edit")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-BNjrbmGz.js"></script> <script type="module" crossorigin src="/assets/index-BOJS6jme.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-so1NV9Au.css"> <link rel="stylesheet" crossorigin href="/assets/index-DG_2rmdm.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -1,5 +1,6 @@
"""Notification router - matches alerts to rules and delivers via channels.""" """Notification router - matches alerts to rules and delivers via channels."""
import asyncio
import logging import logging
import time import time
from datetime import datetime from datetime import datetime
@ -27,55 +28,76 @@ class NotificationRouter:
llm_backend=None, llm_backend=None,
timezone: str = "America/Boise", timezone: str = "America/Boise",
): ):
self._channels: dict[str, NotificationChannel] = {}
self._rules: list[dict] = [] self._rules: list[dict] = []
self._quiet_start = getattr(config, "quiet_hours_start", "22:00") self._quiet_start = getattr(config, "quiet_hours_start", "22:00")
self._quiet_end = getattr(config, "quiet_hours_end", "06:00") self._quiet_end = getattr(config, "quiet_hours_end", "06:00")
self._timezone = timezone self._timezone = timezone
self._dedup_window = getattr(config, "dedup_seconds", 600)
self._recent: dict[tuple, float] = {} # (category, event_key) -> last_sent_time self._recent: dict[tuple, float] = {} # (category, event_key) -> last_sent_time
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
self._connector = connector self._connector = connector
self._config = config
# Create channel instances from config # Load rules from config
channels_config = getattr(config, "channels", [])
for ch_config in channels_config:
if hasattr(ch_config, "__dict__"):
ch_dict = {k: v for k, v in ch_config.__dict__.items() if not k.startswith("_")}
else:
ch_dict = ch_config
if not ch_dict.get("enabled", True):
continue
channel_id = ch_dict.get("id", "")
if not channel_id:
continue
try:
channel = create_channel(ch_dict, connector)
self._channels[channel_id] = channel
logger.debug("Created notification channel: %s (%s)", channel_id, ch_dict.get("type"))
except Exception as e:
logger.warning("Failed to create channel %s: %s", channel_id, e)
# Load rules
rules_config = getattr(config, "rules", []) rules_config = getattr(config, "rules", [])
for rule in rules_config: for rule in rules_config:
if hasattr(rule, "__dict__"): if hasattr(rule, "__dict__"):
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")} rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
else: else:
rule_dict = rule rule_dict = dict(rule) if isinstance(rule, dict) else {}
self._rules.append(rule_dict)
logger.info( # Skip disabled rules
"Notification router initialized: %d channels, %d rules", if not rule_dict.get("enabled", True):
len(self._channels), continue
len(self._rules),
) # Only load condition-triggered rules (scheduled rules handled by scheduler)
if rule_dict.get("trigger_type", "condition") == "condition":
self._rules.append(rule_dict)
logger.info("Notification router initialized: %d condition rules", len(self._rules))
def _create_channel_for_rule(self, rule: dict) -> Optional[NotificationChannel]:
"""Create a channel instance from a rule's inline delivery config."""
delivery_type = rule.get("delivery_type", "")
if delivery_type == "mesh_broadcast":
config = {
"type": "mesh_broadcast",
"channel_index": rule.get("broadcast_channel", 0),
}
elif delivery_type == "mesh_dm":
config = {
"type": "mesh_dm",
"node_ids": rule.get("node_ids", []),
}
elif delivery_type == "email":
config = {
"type": "email",
"smtp_host": rule.get("smtp_host", ""),
"smtp_port": rule.get("smtp_port", 587),
"smtp_user": rule.get("smtp_user", ""),
"smtp_password": rule.get("smtp_password", ""),
"smtp_tls": rule.get("smtp_tls", True),
"from_address": rule.get("from_address", ""),
"recipients": rule.get("recipients", []),
}
elif delivery_type == "webhook":
config = {
"type": "webhook",
"url": rule.get("webhook_url", ""),
"headers": rule.get("webhook_headers", {}),
}
else:
logger.warning("Unknown delivery type: %s", delivery_type)
return None
try:
return create_channel(config, self._connector)
except Exception as e:
logger.warning("Failed to create channel for rule %s: %s", rule.get("name"), e)
return None
async def process_alert(self, alert: dict) -> bool: async def process_alert(self, alert: dict) -> bool:
"""Route an alert through matching rules to channels. """Route an alert through matching rules.
Returns True if alert was delivered to at least one channel. Returns True if alert was delivered to at least one channel.
""" """
@ -99,45 +121,41 @@ class NotificationRouter:
if not rule.get("override_quiet", False): if not rule.get("override_quiet", False):
continue continue
# Check dedup # Check cooldown
cooldown = rule.get("cooldown_minutes", 10) * 60
event_id = alert.get("event_id", alert.get("message", "")[:50]) event_id = alert.get("event_id", alert.get("message", "")[:50])
dedup_key = (category, event_id) rule_name = rule.get("name", "unknown")
dedup_key = (rule_name, category, event_id)
now = time.time() now = time.time()
if dedup_key in self._recent: if dedup_key in self._recent:
if now - self._recent[dedup_key] < self._dedup_window: if now - self._recent[dedup_key] < cooldown:
logger.debug("Skipping duplicate alert: %s", category) logger.debug("Skipping alert (cooldown): %s via %s", category, rule_name)
continue continue
self._recent[dedup_key] = now self._recent[dedup_key] = now
# Deliver to each channel in the rule # Create channel and deliver
channel_ids = rule.get("channel_ids", []) channel = self._create_channel_for_rule(rule)
for channel_id in channel_ids: if not channel:
channel = self._channels.get(channel_id) continue
if not channel:
continue
try: try:
# Summarize for mesh channels if over 200 chars # Summarize for mesh channels if over 200 chars
delivery_alert = alert delivery_alert = alert
message = alert.get("message", "") message = alert.get("message", "")
if channel.channel_type in ("mesh_broadcast", "mesh_dm"): if channel.channel_type in ("mesh_broadcast", "mesh_dm"):
if len(message) > 200: if len(message) > 200:
if self._summarizer: if self._summarizer:
summary = await self._summarizer.summarize(message, max_chars=195) summary = await self._summarizer.summarize(message, max_chars=195)
delivery_alert = {**alert, "message": summary} delivery_alert = {**alert, "message": summary}
else: else:
delivery_alert = {**alert, "message": message[:195] + "..."} delivery_alert = {**alert, "message": message[:195] + "..."}
success = await channel.deliver(delivery_alert, rule) success = await channel.deliver(delivery_alert, rule)
if success: if success:
delivered = True delivered = True
logger.info( logger.info("Alert delivered via %s: %s", rule_name, category)
"Alert delivered via %s: %s", except Exception as e:
channel_id, logger.warning("Rule %s delivery failed: %s", rule_name, e)
category,
)
except Exception as e:
logger.warning("Channel %s delivery failed: %s", channel_id, e)
return delivered return delivered
@ -170,22 +188,26 @@ class NotificationRouter:
except Exception: except Exception:
return False return False
def get_channels(self) -> list[dict]:
"""Get list of configured channels."""
return [
{"id": ch_id, "type": ch.channel_type}
for ch_id, ch in self._channels.items()
]
def get_rules(self) -> list[dict]: def get_rules(self) -> list[dict]:
"""Get list of configured rules.""" """Get list of configured rules."""
return self._rules return self._rules
async def test_channel(self, channel_id: str) -> tuple[bool, str]: async def test_rule(self, rule_index: int) -> tuple[bool, str]:
"""Send a test alert to a specific channel.""" """Send a test alert through a specific rule."""
channel = self._channels.get(channel_id) rules_config = getattr(self._config, "rules", [])
if rule_index < 0 or rule_index >= len(rules_config):
return False, "Rule index out of range"
rule = rules_config[rule_index]
if hasattr(rule, "__dict__"):
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
else:
rule_dict = dict(rule)
channel = self._create_channel_for_rule(rule_dict)
if not channel: if not channel:
return False, "Channel not found: %s" % channel_id return False, "Failed to create delivery channel"
return await channel.test() return await channel.test()
def add_mesh_subscription( def add_mesh_subscription(
@ -196,22 +218,9 @@ class NotificationRouter:
) -> str: ) -> str:
"""Add a mesh DM subscription for a node. """Add a mesh DM subscription for a node.
Creates a channel and rule for the node to receive alerts. Creates a rule for the node to receive alerts.
Returns the rule name. Returns the rule name.
""" """
# Create channel ID
channel_id = "mesh_dm_%s" % node_id
# Create channel if it doesn't exist
if channel_id not in self._channels:
from .channels import MeshDMChannel
channel = MeshDMChannel(
connector=self._connector,
node_ids=[node_id],
)
self._channels[channel_id] = channel
# Create rule
if not rule_name: if not rule_name:
rule_name = "sub_%s" % node_id rule_name = "sub_%s" % node_id
@ -220,15 +229,19 @@ class NotificationRouter:
if rule.get("name") == rule_name: if rule.get("name") == rule_name:
# Update existing rule # Update existing rule
rule["categories"] = categories if categories else [] rule["categories"] = categories if categories else []
rule["channel_ids"] = [channel_id] rule["node_ids"] = [node_id]
return rule_name return rule_name
# Add new rule # Add new rule
self._rules.append({ self._rules.append({
"name": rule_name, "name": rule_name,
"enabled": True,
"trigger_type": "condition",
"categories": categories if categories else [], # Empty = all "categories": categories if categories else [], # Empty = all
"min_severity": "warning", "min_severity": "warning",
"channel_ids": [channel_id], "delivery_type": "mesh_dm",
"node_ids": [node_id],
"cooldown_minutes": 10,
"override_quiet": False, "override_quiet": False,
}) })
@ -236,16 +249,8 @@ class NotificationRouter:
def remove_mesh_subscription(self, node_id: str) -> bool: def remove_mesh_subscription(self, node_id: str) -> bool:
"""Remove a mesh subscription for a node.""" """Remove a mesh subscription for a node."""
channel_id = "mesh_dm_%s" % node_id
rule_name = "sub_%s" % node_id rule_name = "sub_%s" % node_id
# Remove channel
if channel_id in self._channels:
del self._channels[channel_id]
# Remove rule
self._rules = [r for r in self._rules if r.get("name") != rule_name] self._rules = [r for r in self._rules if r.get("name") != rule_name]
return True return True
def get_node_subscriptions(self, node_id: str) -> list[str]: def get_node_subscriptions(self, node_id: str) -> list[str]: