refactor(notifications): complete UX redesign

- Self-contained rules replace abstract channels
- Inline delivery config (broadcast/DM/email/webhook or none)
- quiet_hours_enabled master toggle separate from start/end times
- delivery_type="" valid: rule matches but does not deliver
- Severity dropdown with plain-English descriptions
- Example messages per alert category
- Default baseline rules: Emergency Broadcast, Infrastructure Down, Fire Alert, Severe Weather
- Condition vs Schedule trigger types
- Test and preview buttons per rule
- stream_flood_warning renamed from flood_warning (distinct from packet_flood)
- Categories display with descriptions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-13 14:25:57 +00:00
commit d90b787c12
8 changed files with 614 additions and 370 deletions

View file

@ -1,6 +1,7 @@
"""Alert category registry.
Defines all alertable conditions with human-readable names and descriptions.
Defines all alertable conditions with human-readable names, descriptions,
and example messages showing what users will receive.
"""
ALERT_CATEGORIES = {
@ -9,21 +10,25 @@ ALERT_CATEGORIES = {
"name": "Infrastructure Offline",
"description": "An infrastructure node stopped responding",
"default_severity": "warning",
"example_message": "❌ Mountain Harrison Rptr went offline in Magic Valley.",
},
"critical_node_down": {
"name": "Critical Node Down",
"description": "A node marked as critical went offline",
"default_severity": "critical",
"example_message": "🚨 MHR went offline in Magic Valley. (alert 1/4)",
},
"infra_recovery": {
"name": "Infrastructure Recovery",
"description": "An infrastructure node came back online",
"default_severity": "info",
"example_message": "✅ Mountain Harrison Rptr is back online in Magic Valley.",
},
"new_router": {
"name": "New Router",
"description": "A new router appeared on the mesh",
"default_severity": "info",
"example_message": "📡 New router appeared: Snake River Relay in Wood River Valley.",
},
# Power alerts
@ -31,43 +36,51 @@ ALERT_CATEGORIES = {
"name": "Battery Warning",
"description": "Infrastructure node battery below warning threshold",
"default_severity": "warning",
"example_message": "🔋 BLD-MTN battery low at 35% in Boise Foothills.",
},
"battery_critical": {
"name": "Battery Critical",
"description": "Infrastructure node battery below critical threshold",
"default_severity": "critical",
"example_message": "🔋 MHR battery critical at 18% in Magic Valley.",
},
"battery_emergency": {
"name": "Battery Emergency",
"description": "Infrastructure node battery critically low",
"default_severity": "emergency",
"example_message": "🚨 BLD-MTN battery EMERGENCY at 8% in Boise Foothills.",
},
"battery_trend": {
"name": "Battery Declining",
"description": "Battery showing declining trend over 7 days",
"default_severity": "warning",
"example_message": "🔋 HPR battery declining: 85% → 62% over 7 days (-3.3%/day) in Hagerman.",
},
"power_source_change": {
"name": "Power Source Change",
"description": "Node switched from USB to battery (possible outage)",
"default_severity": "warning",
"example_message": "⚡ MHR switched from USB to battery in Magic Valley. Possible power outage.",
},
"solar_not_charging": {
"name": "Solar Not Charging",
"description": "Solar panel not charging during daylight hours",
"default_severity": "warning",
"example_message": "☀️ BLD-MTN solar not charging in Boise Foothills.",
},
# Utilization alerts
"sustained_high_util": {
"name": "High Utilization",
"description": "Channel utilization elevated for extended period",
"name": "High Channel Utilization",
"description": "Channel airtime elevated for extended period",
"default_severity": "warning",
"example_message": "🔥 MHR at 32% channel utilization for 6+ hours in Magic Valley.",
},
"packet_flood": {
"name": "Packet Flood",
"description": "Node sending excessive packets",
"description": "Node sending excessive packets (possible firmware bug)",
"default_severity": "warning",
"example_message": "📡 BRKN-NODE sent 847 packets in 24h (threshold: 500) in Boise.",
},
# Coverage alerts
@ -75,16 +88,19 @@ ALERT_CATEGORIES = {
"name": "Single Gateway",
"description": "Infrastructure node dropped to single gateway coverage",
"default_severity": "warning",
"example_message": "📶 HPR dropped to single gateway coverage in Hagerman.",
},
"feeder_offline": {
"name": "Feeder Offline",
"description": "A feeder gateway stopped responding",
"default_severity": "warning",
"example_message": "📡 Feeder gateway AIDA-N2 went offline.",
},
"region_total_blackout": {
"name": "Region Blackout",
"description": "All infrastructure in a region is offline",
"default_severity": "emergency",
"example_message": "🚨 TOTAL BLACKOUT: All infrastructure in Magic Valley is offline!",
},
# Health score alerts
@ -92,11 +108,13 @@ ALERT_CATEGORIES = {
"name": "Mesh Health Low",
"description": "Overall mesh health score below threshold",
"default_severity": "warning",
"example_message": "📉 Mesh Health: Score dropped to 62 (Warning threshold: 70).",
},
"region_score_low": {
"name": "Region Health Low",
"description": "A region's health score below threshold",
"default_severity": "warning",
"example_message": "📉 Magic Valley health score dropped to 55 (threshold: 60).",
},
# Environmental alerts
@ -104,36 +122,43 @@ ALERT_CATEGORIES = {
"name": "Severe Weather",
"description": "NWS warning or advisory for mesh area",
"default_severity": "warning",
"example_message": "⚠️ Red Flag Warning — Twin Falls, Jerome, Cassia counties until May 14 04:00 MDT.",
},
"hf_blackout": {
"name": "HF Radio Blackout",
"description": "R3+ solar event degrading HF propagation",
"default_severity": "warning",
"example_message": "📻 R3 HF Radio Blackout — HF propagation degraded for several hours.",
},
"tropospheric_ducting": {
"name": "Tropospheric Ducting",
"description": "Atmospheric conditions extending VHF/UHF range",
"default_severity": "info",
"example_message": "📡 Tropospheric Ducting: Surface duct detected, dM/dz -45 M-units/km. Extended VHF/UHF range possible.",
},
"wildfire_proximity": {
"name": "Fire Near Mesh",
"description": "Wildfire detected within configured distance",
"description": "Wildfire detected within configured distance of mesh infrastructure",
"default_severity": "warning",
"example_message": "🔥 Rock Creek Fire — 1,240 ac, 15% contained, 24 km SSW of MHR.",
},
"new_ignition": {
"name": "New Fire Ignition",
"description": "Satellite hotspot not matching any known fire",
"description": "Satellite hotspot not matching any known fire perimeter",
"default_severity": "warning",
"example_message": "🛰️ New Ignition: Satellite fire detection at 42.32°N, 114.30°W — high confidence, not near any known fire.",
},
"flood_warning": {
"name": "Flood Warning",
"description": "Stream gauge exceeds flood threshold",
"stream_flood_warning": {
"name": "Stream Flood Warning",
"description": "River gauge exceeds flood stage threshold",
"default_severity": "warning",
"example_message": "🌊 Snake River nr Twin Falls at 12.8 ft (flood stage: 13.0 ft).",
},
"road_closure": {
"name": "Road Closure",
"description": "Full road closure on monitored corridor",
"default_severity": "warning",
"example_message": "🚧 I-84 EB closed at MP 173 — full closure due to wildfire smoke.",
},
}
@ -146,6 +171,7 @@ def get_category(category_id: str) -> dict:
"name": category_id.replace("_", " ").title(),
"description": f"Alert type: {category_id}",
"default_severity": "info",
"example_message": f"Alert: {category_id}",
}

View file

@ -29,10 +29,11 @@ class NotificationRouter:
timezone: str = "America/Boise",
):
self._rules: list[dict] = []
self._quiet_enabled = getattr(config, "quiet_hours_enabled", True)
self._quiet_start = getattr(config, "quiet_hours_start", "22:00")
self._quiet_end = getattr(config, "quiet_hours_end", "06:00")
self._timezone = timezone
self._recent: dict[tuple, float] = {} # (category, event_key) -> last_sent_time
self._recent: dict[tuple, float] = {} # (rule_name, category, event_key) -> last_sent_time
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
self._connector = connector
self._config = config
@ -56,9 +57,16 @@ class NotificationRouter:
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."""
"""Create a channel instance from a rule's inline delivery config.
Returns None if delivery_type is empty or invalid.
"""
delivery_type = rule.get("delivery_type", "")
# Empty delivery type is valid - rule exists but doesn't deliver
if not delivery_type:
return None
if delivery_type == "mesh_broadcast":
config = {
"type": "mesh_broadcast",
@ -87,13 +95,13 @@ class NotificationRouter:
"headers": rule.get("webhook_headers", {}),
}
else:
logger.warning("Unknown delivery type: %s", delivery_type)
logger.warning("Unknown delivery type '%s' in rule '%s'", delivery_type, rule.get("name"))
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)
logger.warning("Failed to create channel for rule '%s': %s", rule.get("name"), e)
return None
async def process_alert(self, alert: dict) -> bool:
@ -106,6 +114,8 @@ class NotificationRouter:
delivered = False
for rule in self._rules:
rule_name = rule.get("name", "unnamed")
# Check category match
rule_categories = rule.get("categories", [])
if rule_categories and category not in rule_categories:
@ -116,15 +126,18 @@ class NotificationRouter:
if not self._severity_meets(severity, min_severity):
continue
# Check quiet hours (emergencies and criticals override)
if self._in_quiet_hours() and severity not in ("emergency", "critical"):
if not rule.get("override_quiet", False):
continue
# Check quiet hours (only if quiet hours are enabled globally)
if self._quiet_enabled and self._in_quiet_hours():
# Emergencies and criticals always go through
if severity not in ("emergency", "critical"):
# Check if rule overrides quiet hours
if not rule.get("override_quiet", False):
logger.debug("Skipping alert (quiet hours): %s via %s", category, rule_name)
continue
# Check cooldown
cooldown = rule.get("cooldown_minutes", 10) * 60
event_id = alert.get("event_id", alert.get("message", "")[:50])
rule_name = rule.get("name", "unknown")
dedup_key = (rule_name, category, event_id)
now = time.time()
if dedup_key in self._recent:
@ -133,9 +146,19 @@ class NotificationRouter:
continue
self._recent[dedup_key] = now
# Log rule match
logger.info("Rule '%s' matched alert: %s (%s)", rule_name, category, severity)
# Check if rule has delivery configured
delivery_type = rule.get("delivery_type", "")
if not delivery_type:
logger.info("Rule '%s' matched but has no delivery configured", rule_name)
continue
# Create channel and deliver
channel = self._create_channel_for_rule(rule)
if not channel:
logger.warning("Rule '%s' failed to create delivery channel", rule_name)
continue
try:
@ -153,9 +176,9 @@ class NotificationRouter:
success = await channel.deliver(delivery_alert, rule)
if success:
delivered = True
logger.info("Alert delivered via %s: %s", rule_name, category)
logger.info("Alert delivered via rule '%s': %s", rule_name, category)
except Exception as e:
logger.warning("Rule %s delivery failed: %s", rule_name, e)
logger.warning("Rule '%s' delivery failed: %s", rule_name, e)
return delivered
@ -170,6 +193,9 @@ class NotificationRouter:
def _in_quiet_hours(self) -> bool:
"""Check if current time is within quiet hours."""
if not self._quiet_enabled:
return False
try:
from zoneinfo import ZoneInfo
tz = ZoneInfo(self._timezone)
@ -204,12 +230,69 @@ class NotificationRouter:
else:
rule_dict = dict(rule)
# Check if delivery is configured
if not rule_dict.get("delivery_type"):
return False, "No delivery method configured for this rule"
channel = self._create_channel_for_rule(rule_dict)
if not channel:
return False, "Failed to create delivery channel"
return await channel.test()
async def preview_rule(self, rule_index: int) -> dict:
"""Preview what a rule would match right now.
Returns:
{
"matches": bool,
"conditions": [...], # Current conditions that match
"preview": str, # Example message
}
"""
rules_config = getattr(self._config, "rules", [])
if rule_index < 0 or rule_index >= len(rules_config):
return {"matches": False, "conditions": [], "preview": "Invalid rule index"}
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)
# For condition rules, show example based on categories
if rule_dict.get("trigger_type", "condition") == "condition":
from .categories import get_category
categories = rule_dict.get("categories", [])
if not categories:
# All categories - show first example
example = get_category("infra_offline")
return {
"matches": True,
"conditions": ["All alert categories"],
"preview": example.get("example_message", "Alert notification"),
}
else:
# Show example from first category
cat_info = get_category(categories[0])
return {
"matches": True,
"conditions": [get_category(c)["name"] for c in categories],
"preview": cat_info.get("example_message", f"Alert: {categories[0]}"),
}
# For schedule rules, generate preview report
elif rule_dict.get("trigger_type") == "schedule":
message_type = rule_dict.get("message_type", "mesh_health_summary")
return {
"matches": True,
"conditions": [f"Scheduled: {rule_dict.get('schedule_frequency', 'daily')}"],
"preview": f"[{message_type}] Report content would appear here",
}
return {"matches": False, "conditions": [], "preview": "Unknown rule type"}
def add_mesh_subscription(
self,
node_id: str,