mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 09:24:44 +02:00
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:
parent
b4f7e24c26
commit
d90b787c12
8 changed files with 614 additions and 370 deletions
|
|
@ -448,7 +448,7 @@ class NotificationRuleConfig:
|
|||
custom_message: str = ""
|
||||
|
||||
# Delivery type
|
||||
delivery_type: str = "mesh_broadcast" # mesh_broadcast, mesh_dm, email, webhook
|
||||
delivery_type: str = "" # mesh_broadcast, mesh_dm, email, webhook
|
||||
|
||||
# Mesh broadcast fields
|
||||
broadcast_channel: int = 0
|
||||
|
|
@ -482,6 +482,7 @@ class NotificationsConfig:
|
|||
"""Notification system settings."""
|
||||
|
||||
enabled: bool = False
|
||||
quiet_hours_enabled: bool = True # Master toggle for quiet hours
|
||||
quiet_hours_start: str = "22:00"
|
||||
quiet_hours_end: str = "06:00"
|
||||
rules: list = field(default_factory=list) # List of NotificationRuleConfig
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -8,8 +8,8 @@
|
|||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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">
|
||||
<script type="module" crossorigin src="/assets/index-BOJS6jme.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DG_2rmdm.css">
|
||||
<script type="module" crossorigin src="/assets/index-utMF5PG3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D0mCSizv.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue