mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 15:14:45 +02:00
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:
parent
3fa7b9fe5e
commit
b4f7e24c26
9 changed files with 1248 additions and 1095 deletions
|
|
@ -226,59 +226,70 @@ notifications:
|
|||
enabled: false
|
||||
quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours
|
||||
quiet_hours_end: "06:00"
|
||||
dedup_seconds: 600 # Don't resend same alert within this window
|
||||
|
||||
# Notification channels
|
||||
channels:
|
||||
# Mesh broadcast - posts to mesh channel
|
||||
- id: "mesh-channel"
|
||||
type: mesh_broadcast
|
||||
# Notification rules - each rule is self-contained with its own delivery config
|
||||
rules:
|
||||
# All emergencies -> mesh broadcast
|
||||
- name: "Emergency Broadcast"
|
||||
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)
|
||||
# - id: "email-admin"
|
||||
# type: email
|
||||
# Example: Fire alerts -> email
|
||||
# - name: "Fire Alerts Email"
|
||||
# enabled: true
|
||||
# trigger_type: condition
|
||||
# categories: ["wildfire_proximity", "new_ignition"]
|
||||
# min_severity: "advisory"
|
||||
# delivery_type: email
|
||||
# smtp_host: "smtp.gmail.com"
|
||||
# smtp_port: 587
|
||||
# smtp_user: "you@gmail.com"
|
||||
# smtp_password: "${SMTP_PASSWORD}" # Use env var
|
||||
# smtp_password: "${SMTP_PASSWORD}"
|
||||
# smtp_tls: true
|
||||
# from_address: "meshai@yourdomain.com"
|
||||
# recipients: ["admin@yourdomain.com"]
|
||||
# cooldown_minutes: 30
|
||||
|
||||
# Example: Webhook (Discord, Slack, ntfy, Home Assistant)
|
||||
# - id: "discord-alerts"
|
||||
# type: webhook
|
||||
# url: "https://discord.com/api/webhooks/..."
|
||||
|
||||
# - 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"]
|
||||
# Example: All warnings -> Discord webhook
|
||||
# - name: "Discord Alerts"
|
||||
# enabled: true
|
||||
# trigger_type: condition
|
||||
# categories: []
|
||||
# min_severity: "warning"
|
||||
# channel_ids: ["mesh-channel"]
|
||||
# delivery_type: webhook
|
||||
# webhook_url: "https://discord.com/api/webhooks/..."
|
||||
# cooldown_minutes: 10
|
||||
|
||||
# === WEB DASHBOARD ===
|
||||
dashboard:
|
||||
enabled: true
|
||||
port: 8080
|
||||
host: "0.0.0.0"
|
||||
# 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 ===
|
||||
dashboard:
|
||||
enabled: true
|
||||
port: 8080
|
||||
host: "0.0.0.0"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
124
meshai/config.py
124
meshai/config.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,45 +1,10 @@
|
|||
"""Notification API routes."""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
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")
|
||||
async def get_categories():
|
||||
"""Get all alert categories with descriptions."""
|
||||
|
|
@ -50,34 +15,6 @@ async def get_categories():
|
|||
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")
|
||||
async def get_rules(request: Request):
|
||||
"""Get configured notification rules."""
|
||||
|
|
@ -87,27 +24,12 @@ async def get_rules(request: Request):
|
|||
return notification_router.get_rules()
|
||||
|
||||
|
||||
@router.post("/rules")
|
||||
async def create_rule(request: Request, rule: RuleCreate):
|
||||
"""Create a new notification rule."""
|
||||
# This would require runtime config modification
|
||||
raise HTTPException(status_code=501, detail="Rule creation requires config file edit")
|
||||
@router.post("/rules/{rule_index}/test")
|
||||
async def test_rule(request: Request, rule_index: int):
|
||||
"""Send a test alert through a specific rule."""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
|
||||
@router.get("/quiet-hours")
|
||||
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")
|
||||
success, message = await notification_router.test_rule(rule_index)
|
||||
return {"success": success, "message": message}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
425
meshai/dashboard/static/assets/index-BOJS6jme.js
Normal file
425
meshai/dashboard/static/assets/index-BOJS6jme.js
Normal file
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-BNjrbmGz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-so1NV9Au.css">
|
||||
<script type="module" crossorigin src="/assets/index-BOJS6jme.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DG_2rmdm.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Notification router - matches alerts to rules and delivers via channels."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
|
@ -27,55 +28,76 @@ class NotificationRouter:
|
|||
llm_backend=None,
|
||||
timezone: str = "America/Boise",
|
||||
):
|
||||
self._channels: dict[str, NotificationChannel] = {}
|
||||
self._rules: list[dict] = []
|
||||
self._quiet_start = getattr(config, "quiet_hours_start", "22:00")
|
||||
self._quiet_end = getattr(config, "quiet_hours_end", "06:00")
|
||||
self._timezone = timezone
|
||||
self._dedup_window = getattr(config, "dedup_seconds", 600)
|
||||
self._recent: dict[tuple, float] = {} # (category, event_key) -> last_sent_time
|
||||
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
|
||||
self._connector = connector
|
||||
self._config = config
|
||||
|
||||
# Create channel instances 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
|
||||
# Load rules from config
|
||||
rules_config = getattr(config, "rules", [])
|
||||
for rule in rules_config:
|
||||
if hasattr(rule, "__dict__"):
|
||||
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
|
||||
else:
|
||||
rule_dict = rule
|
||||
self._rules.append(rule_dict)
|
||||
rule_dict = dict(rule) if isinstance(rule, dict) else {}
|
||||
|
||||
logger.info(
|
||||
"Notification router initialized: %d channels, %d rules",
|
||||
len(self._channels),
|
||||
len(self._rules),
|
||||
)
|
||||
# Skip disabled rules
|
||||
if not rule_dict.get("enabled", True):
|
||||
continue
|
||||
|
||||
# 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:
|
||||
"""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.
|
||||
"""
|
||||
|
|
@ -99,45 +121,41 @@ class NotificationRouter:
|
|||
if not rule.get("override_quiet", False):
|
||||
continue
|
||||
|
||||
# Check dedup
|
||||
# Check cooldown
|
||||
cooldown = rule.get("cooldown_minutes", 10) * 60
|
||||
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()
|
||||
if dedup_key in self._recent:
|
||||
if now - self._recent[dedup_key] < self._dedup_window:
|
||||
logger.debug("Skipping duplicate alert: %s", category)
|
||||
if now - self._recent[dedup_key] < cooldown:
|
||||
logger.debug("Skipping alert (cooldown): %s via %s", category, rule_name)
|
||||
continue
|
||||
self._recent[dedup_key] = now
|
||||
|
||||
# Deliver to each channel in the rule
|
||||
channel_ids = rule.get("channel_ids", [])
|
||||
for channel_id in channel_ids:
|
||||
channel = self._channels.get(channel_id)
|
||||
if not channel:
|
||||
continue
|
||||
# Create channel and deliver
|
||||
channel = self._create_channel_for_rule(rule)
|
||||
if not channel:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Summarize for mesh channels if over 200 chars
|
||||
delivery_alert = alert
|
||||
message = alert.get("message", "")
|
||||
if channel.channel_type in ("mesh_broadcast", "mesh_dm"):
|
||||
if len(message) > 200:
|
||||
if self._summarizer:
|
||||
summary = await self._summarizer.summarize(message, max_chars=195)
|
||||
delivery_alert = {**alert, "message": summary}
|
||||
else:
|
||||
delivery_alert = {**alert, "message": message[:195] + "..."}
|
||||
try:
|
||||
# Summarize for mesh channels if over 200 chars
|
||||
delivery_alert = alert
|
||||
message = alert.get("message", "")
|
||||
if channel.channel_type in ("mesh_broadcast", "mesh_dm"):
|
||||
if len(message) > 200:
|
||||
if self._summarizer:
|
||||
summary = await self._summarizer.summarize(message, max_chars=195)
|
||||
delivery_alert = {**alert, "message": summary}
|
||||
else:
|
||||
delivery_alert = {**alert, "message": message[:195] + "..."}
|
||||
|
||||
success = await channel.deliver(delivery_alert, rule)
|
||||
if success:
|
||||
delivered = True
|
||||
logger.info(
|
||||
"Alert delivered via %s: %s",
|
||||
channel_id,
|
||||
category,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Channel %s delivery failed: %s", channel_id, e)
|
||||
success = await channel.deliver(delivery_alert, rule)
|
||||
if success:
|
||||
delivered = True
|
||||
logger.info("Alert delivered via %s: %s", rule_name, category)
|
||||
except Exception as e:
|
||||
logger.warning("Rule %s delivery failed: %s", rule_name, e)
|
||||
|
||||
return delivered
|
||||
|
||||
|
|
@ -170,22 +188,26 @@ class NotificationRouter:
|
|||
except Exception:
|
||||
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]:
|
||||
"""Get list of configured rules."""
|
||||
return self._rules
|
||||
|
||||
async def test_channel(self, channel_id: str) -> tuple[bool, str]:
|
||||
"""Send a test alert to a specific channel."""
|
||||
channel = self._channels.get(channel_id)
|
||||
async def test_rule(self, rule_index: int) -> tuple[bool, str]:
|
||||
"""Send a test alert through a specific rule."""
|
||||
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:
|
||||
return False, "Channel not found: %s" % channel_id
|
||||
return False, "Failed to create delivery channel"
|
||||
|
||||
return await channel.test()
|
||||
|
||||
def add_mesh_subscription(
|
||||
|
|
@ -196,22 +218,9 @@ class NotificationRouter:
|
|||
) -> str:
|
||||
"""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.
|
||||
"""
|
||||
# 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:
|
||||
rule_name = "sub_%s" % node_id
|
||||
|
||||
|
|
@ -220,15 +229,19 @@ class NotificationRouter:
|
|||
if rule.get("name") == rule_name:
|
||||
# Update existing rule
|
||||
rule["categories"] = categories if categories else []
|
||||
rule["channel_ids"] = [channel_id]
|
||||
rule["node_ids"] = [node_id]
|
||||
return rule_name
|
||||
|
||||
# Add new rule
|
||||
self._rules.append({
|
||||
"name": rule_name,
|
||||
"enabled": True,
|
||||
"trigger_type": "condition",
|
||||
"categories": categories if categories else [], # Empty = all
|
||||
"min_severity": "warning",
|
||||
"channel_ids": [channel_id],
|
||||
"delivery_type": "mesh_dm",
|
||||
"node_ids": [node_id],
|
||||
"cooldown_minutes": 10,
|
||||
"override_quiet": False,
|
||||
})
|
||||
|
||||
|
|
@ -236,16 +249,8 @@ class NotificationRouter:
|
|||
|
||||
def remove_mesh_subscription(self, node_id: str) -> bool:
|
||||
"""Remove a mesh subscription for a node."""
|
||||
channel_id = "mesh_dm_%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]
|
||||
|
||||
return True
|
||||
|
||||
def get_node_subscriptions(self, node_id: str) -> list[str]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue