fix(notifications): test button sends real data preview, not generic string

- Tests check current conditions against rule categories/severity
- Shows actual alert messages that would fire right now
- Falls back to example messages from category registry if no matches
- Preview mode shows without sending, Send Test delivers with [TEST] prefix
- Mesh delivery applies real summarization so preview matches actual output
- Added test dialog UI showing conditions matched and preview messages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-13 23:32:22 +00:00
commit 0ad37e55d9
8 changed files with 1198 additions and 816 deletions

View file

@ -1098,7 +1098,17 @@ export default function Notifications() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [success, setSuccess] = useState<string | null>(null)
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) const [testResult, setTestResult] = useState<{
success?: boolean;
message?: string;
conditions_matched?: number;
preview_messages?: string[];
is_example?: boolean;
delivered?: boolean;
delivery_method?: string;
delivery_result?: string;
} | null>(null)
const [testDialog, setTestDialog] = useState<{ open: boolean; ruleIndex: number; loading: boolean }>({ open: false, ruleIndex: -1, loading: false })
const [showTemplates, setShowTemplates] = useState(false) const [showTemplates, setShowTemplates] = useState(false)
const [hasChanges, setHasChanges] = useState(false) const [hasChanges, setHasChanges] = useState(false)
@ -1223,17 +1233,46 @@ export default function Notifications() {
} }
const testRule = async (index: number) => { const testRule = async (index: number) => {
// Open dialog and show preview first
setTestDialog({ open: true, ruleIndex: index, loading: true })
try { try {
const res = await fetch(`/api/notifications/rules/${index}/test`, { method: 'POST' }) const res = await fetch(`/api/notifications/rules/${index}/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ send: false }) // Preview only
})
const result = await res.json() const result = await res.json()
setTestResult(result) setTestResult(result)
setTimeout(() => setTestResult(null), 5000) setTestDialog(d => ({ ...d, loading: false }))
} catch { } catch {
setTestResult({ success: false, message: 'Test failed' }) setTestResult({ success: false, message: 'Failed to get preview' })
setTimeout(() => setTestResult(null), 5000) setTestDialog(d => ({ ...d, loading: false }))
} }
} }
const sendTest = async () => {
const index = testDialog.ruleIndex
setTestDialog(d => ({ ...d, loading: true }))
try {
const res = await fetch(`/api/notifications/rules/${index}/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ send: true }) // Actually send
})
const result = await res.json()
setTestResult(result)
setTestDialog(d => ({ ...d, loading: false }))
} catch {
setTestResult({ success: false, message: 'Failed to send test' })
setTestDialog(d => ({ ...d, loading: false }))
}
}
const closeTestDialog = () => {
setTestDialog({ open: false, ruleIndex: -1, loading: false })
setTestResult(null)
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
@ -1252,6 +1291,85 @@ export default function Notifications() {
return ( return (
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
{/* Test Dialog */}
{testDialog.open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-[#1a2332] border border-[#2a3a4a] rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[80vh] overflow-auto">
<div className="p-4 border-b border-[#2a3a4a] flex items-center justify-between">
<h3 className="text-lg font-semibold">Test Notification Rule</h3>
<button onClick={closeTestDialog} className="text-slate-500 hover:text-slate-300">
<X size={20} />
</button>
</div>
<div className="p-4 space-y-4">
{testDialog.loading ? (
<div className="flex items-center justify-center py-8">
<div className="text-slate-400">Checking conditions...</div>
</div>
) : testResult ? (
<>
{/* Conditions summary */}
<div className="flex items-center gap-2">
{testResult.conditions_matched && testResult.conditions_matched > 0 ? (
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-sm">
{testResult.conditions_matched} condition{testResult.conditions_matched !== 1 ? 's' : ''} match
</span>
) : (
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 rounded text-sm">
No current matches showing examples
</span>
)}
</div>
{/* Preview messages */}
<div className="space-y-2">
<div className="text-sm text-slate-500">
{testResult.is_example ? 'Example messages:' : 'Current alerts that would fire:'}
</div>
{testResult.preview_messages?.map((msg, i) => (
<div key={i} className="p-3 bg-slate-800 rounded text-sm font-mono break-words">
{msg}
</div>
))}
</div>
{/* Delivery result */}
{testResult.delivered && (
<div className="p-3 bg-green-500/10 border border-green-500/30 rounded text-green-400 text-sm">
{testResult.delivery_result}
</div>
)}
{/* Legacy format support */}
{testResult.message && !testResult.preview_messages && (
<div className={`p-3 rounded text-sm ${testResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
{testResult.message}
</div>
)}
</>
) : null}
</div>
<div className="p-4 border-t border-[#2a3a4a] flex justify-end gap-2">
<button
onClick={closeTestDialog}
className="px-4 py-2 text-slate-400 hover:text-slate-200"
>
Close
</button>
{testResult && !testResult.delivered && testResult.delivery_method && (
<button
onClick={sendTest}
disabled={testDialog.loading}
className="px-4 py-2 bg-accent hover:bg-accent/80 rounded disabled:opacity-50"
>
{testDialog.loading ? 'Sending...' : 'Send Test'}
</button>
)}
</div>
</div>
</div>
)}
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>

View file

@ -1,35 +1,84 @@
"""Notification API routes.""" """Notification API routes."""
from fastapi import APIRouter, Request, HTTPException from fastapi import APIRouter, Request, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/notifications", tags=["notifications"]) from typing import Optional
router = APIRouter(prefix="/notifications", tags=["notifications"])
@router.get("/categories")
async def get_categories():
"""Get all alert categories with descriptions.""" class TestRequest(BaseModel):
try: """Request body for test endpoint."""
from ...notifications.categories import list_categories send: bool = False # True = actually deliver, False = preview only
return list_categories()
except ImportError:
return [] @router.get("/categories")
async def get_categories():
"""Get all alert categories with descriptions."""
@router.get("/rules") try:
async def get_rules(request: Request): from ...notifications.categories import list_categories
"""Get configured notification rules.""" return list_categories()
notification_router = getattr(request.app.state, "notification_router", None) except ImportError:
if not notification_router: return []
return []
return notification_router.get_rules()
@router.get("/rules")
async def get_rules(request: Request):
@router.post("/rules/{rule_index}/test") """Get configured notification rules."""
async def test_rule(request: Request, rule_index: int): notification_router = getattr(request.app.state, "notification_router", None)
"""Send a test alert through a specific rule.""" if not notification_router:
notification_router = getattr(request.app.state, "notification_router", None) return []
if not notification_router: return notification_router.get_rules()
raise HTTPException(status_code=404, detail="Notification router not configured")
success, message = await notification_router.test_rule(rule_index) @router.post("/rules/{rule_index}/test")
return {"success": success, "message": message} async def test_rule(request: Request, rule_index: int, body: Optional[TestRequest] = None):
"""Test a notification rule against current conditions.
Returns:
{
"conditions_matched": int, # Number of matching alerts
"preview_messages": list[str], # Messages that would send
"is_example": bool, # True if using example messages
"delivered": bool, # True if actually sent
"delivery_method": str, # e.g. "mesh_broadcast"
"delivery_result": str, # Result message
}
"""
notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured")
alert_engine = getattr(request.app.state, "alert_engine", None)
env_store = getattr(request.app.state, "env_store", None)
send = body.send if body else False
result = await notification_router.test_rule_with_conditions(
rule_index,
alert_engine=alert_engine,
env_store=env_store,
send=send,
)
return result
@router.post("/rules/{rule_index}/preview")
async def preview_rule(request: Request, rule_index: int):
"""Preview what a rule would match right now (without sending)."""
notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured")
alert_engine = getattr(request.app.state, "alert_engine", None)
env_store = getattr(request.app.state, "env_store", None)
result = await notification_router.test_rule_with_conditions(
rule_index,
alert_engine=alert_engine,
env_store=env_store,
send=False,
)
return result

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-CYCOCObI.js"></script> <script type="module" crossorigin src="/assets/index-BXyt_EfK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DbmGQdf0.css"> <link rel="stylesheet" crossorigin href="/assets/index-CtFYHJy4.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -1,308 +1,317 @@
"""Notification channel implementations.""" """Notification channel implementations."""
import asyncio import asyncio
import logging import logging
import smtplib import smtplib
import ssl import ssl
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
import httpx import httpx
if TYPE_CHECKING: if TYPE_CHECKING:
from ..connector import MeshConnector from ..connector import MeshConnector
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class NotificationChannel(ABC): class NotificationChannel(ABC):
"""Base class for notification delivery channels.""" """Base class for notification delivery channels."""
channel_type: str = "base" channel_type: str = "base"
@abstractmethod @abstractmethod
async def deliver(self, alert: dict, rule: dict) -> bool: async def deliver(self, alert: dict, rule: dict) -> bool:
"""Send alert. Returns True on success.""" """Send alert. Returns True on success."""
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
async def test(self) -> tuple[bool, str]: async def test(self) -> tuple[bool, str]:
"""Send test message. Returns (success, message).""" """Send test message. Returns (success, message)."""
raise NotImplementedError raise NotImplementedError
class MeshBroadcastChannel(NotificationChannel): class MeshBroadcastChannel(NotificationChannel):
"""Post alert to mesh channel.""" """Post alert to mesh channel."""
channel_type = "mesh_broadcast" channel_type = "mesh_broadcast"
def __init__(self, connector: "MeshConnector", channel_index: int = 0): def __init__(self, connector: "MeshConnector", channel_index: int = 0):
self._connector = connector self._connector = connector
self._channel = channel_index self._channel = channel_index
async def deliver(self, alert: dict, rule: dict) -> bool: async def deliver(self, alert: dict, rule: dict) -> bool:
"""Send alert to mesh channel.""" """Send alert to mesh channel."""
if not self._connector: if not self._connector:
logger.warning("No mesh connector available") logger.warning("No mesh connector available")
return False return False
try: try:
message = alert.get("message", "") message = alert.get("message", "")
self._connector.send_message( self._connector.send_message(
text=message, text=message,
destination=None, destination=None,
channel=self._channel, channel=self._channel,
) )
logger.info("Broadcast alert to channel %d", self._channel) logger.info("Broadcast alert to channel %d", self._channel)
return True return True
except Exception as e: except Exception as e:
logger.error("Failed to broadcast alert: %s", e) logger.error("Failed to broadcast alert: %s", e)
return False return False
async def test(self) -> tuple[bool, str]: async def test(self) -> tuple[bool, str]:
"""Send test broadcast.""" """Send test broadcast."""
try: try:
self._connector.send_message( self._connector.send_message(
text="[TEST] MeshAI notification system test", text="[TEST] MeshAI notification system test",
destination=None, destination=None,
channel=self._channel, channel=self._channel,
) )
return True, "Test message sent to channel %d" % self._channel return True, "Test message sent to channel %d" % self._channel
except Exception as e: except Exception as e:
return False, "Failed to send test: %s" % e return False, "Failed to send test: %s" % e
class MeshDMChannel(NotificationChannel): class MeshDMChannel(NotificationChannel):
"""DM alert to specific node IDs.""" """DM alert to specific node IDs."""
channel_type = "mesh_dm" channel_type = "mesh_dm"
def __init__(self, connector: "MeshConnector", node_ids: list[str]): def __init__(self, connector: "MeshConnector", node_ids: list[str]):
self._connector = connector self._connector = connector
self._node_ids = node_ids self._node_ids = node_ids
async def deliver(self, alert: dict, rule: dict) -> bool: async def deliver(self, alert: dict, rule: dict) -> bool:
"""Send alert via DM to configured nodes.""" """Send alert via DM to configured nodes."""
if not self._connector: if not self._connector:
return False return False
message = alert.get("message", "") message = alert.get("message", "")
success = True success = True
for node_id in self._node_ids: for node_id in self._node_ids:
try: try:
dest = int(node_id) if node_id.isdigit() else node_id dest = int(node_id) if node_id.isdigit() else node_id
self._connector.send_message(text=message, destination=dest, channel=0) self._connector.send_message(text=message, destination=dest, channel=0)
except Exception as e: except Exception as e:
logger.error("Failed to DM %s: %s", node_id, e) logger.error("Failed to DM %s: %s", node_id, e)
success = False success = False
return success return success
async def test(self) -> tuple[bool, str]: async def test(self) -> tuple[bool, str]:
"""Send test DM to all configured nodes.""" """Send test DM to all configured nodes."""
if not self._node_ids: if not self._node_ids:
return False, "No node IDs configured" return False, "No node IDs configured"
try: try:
for node_id in self._node_ids: for node_id in self._node_ids:
dest = int(node_id) if node_id.isdigit() else node_id dest = int(node_id) if node_id.isdigit() else node_id
self._connector.send_message( self._connector.send_message(
text="[TEST] MeshAI notification test", text="[TEST] MeshAI notification test",
destination=dest, destination=dest,
channel=0, channel=0,
) )
return True, "Test DMs sent to %d nodes" % len(self._node_ids) return True, "Test DMs sent to %d nodes" % len(self._node_ids)
except Exception as e: except Exception as e:
return False, "Failed to send test DMs: %s" % e return False, "Failed to send test DMs: %s" % e
class EmailChannel(NotificationChannel): class EmailChannel(NotificationChannel):
"""Send alert via SMTP email.""" """Send alert via SMTP email."""
channel_type = "email" channel_type = "email"
def __init__( def __init__(
self, self,
smtp_host: str, smtp_host: str,
smtp_port: int, smtp_port: int,
smtp_user: str, smtp_user: str,
smtp_password: str, smtp_password: str,
smtp_tls: bool, smtp_tls: bool,
from_address: str, from_address: str,
recipients: list[str], recipients: list[str],
): ):
self._host = smtp_host self._host = smtp_host
self._port = smtp_port self._port = smtp_port
self._user = smtp_user self._user = smtp_user
self._password = smtp_password self._password = smtp_password
self._tls = smtp_tls self._tls = smtp_tls
self._from = from_address self._from = from_address
self._recipients = recipients self._recipients = recipients
async def deliver(self, alert: dict, rule: dict) -> bool: async def deliver(self, alert: dict, rule: dict) -> bool:
"""Send alert via email.""" """Send alert via email."""
if not self._recipients: if not self._recipients:
return False return False
alert_type = alert.get("type", "alert") alert_type = alert.get("type", "alert")
severity = alert.get("severity", "info").upper() severity = alert.get("severity", "info").upper()
message = alert.get("message", "") message = alert.get("message", "")
subject = "[MeshAI %s] %s" % (severity, alert_type.replace("_", " ").title()) subject = "[MeshAI %s] %s" % (severity, alert_type.replace("_", " ").title())
body = "MeshAI Alert\n\nType: %s\nSeverity: %s\nTime: %s\n\n%s\n\n---\nAutomated message from MeshAI." % ( body = "MeshAI Alert\n\nType: %s\nSeverity: %s\nTime: %s\n\n%s\n\n---\nAutomated message from MeshAI." % (
alert_type, severity, time.strftime("%Y-%m-%d %H:%M:%S"), message alert_type, severity, time.strftime("%Y-%m-%d %H:%M:%S"), message
) )
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._send_email, subject, body) await loop.run_in_executor(None, self._send_email, subject, body)
return True return True
except Exception as e: except Exception as e:
logger.error("Failed to send email: %s", e) logger.error("Failed to send email: %s", e)
return False return False
def _send_email(self, subject: str, body: str): def _send_email(self, subject: str, body: str):
msg = MIMEMultipart() msg = MIMEMultipart()
msg["From"] = self._from msg["From"] = self._from
msg["To"] = ", ".join(self._recipients) msg["To"] = ", ".join(self._recipients)
msg["Subject"] = subject msg["Subject"] = subject
msg.attach(MIMEText(body, "plain")) msg.attach(MIMEText(body, "plain"))
if self._tls: if self._tls:
context = ssl.create_default_context() context = ssl.create_default_context()
with smtplib.SMTP(self._host, self._port) as server: with smtplib.SMTP(self._host, self._port) as server:
server.starttls(context=context) server.starttls(context=context)
if self._user and self._password: if self._user and self._password:
server.login(self._user, self._password) server.login(self._user, self._password)
server.sendmail(self._from, self._recipients, msg.as_string()) server.sendmail(self._from, self._recipients, msg.as_string())
else: else:
with smtplib.SMTP(self._host, self._port) as server: with smtplib.SMTP(self._host, self._port) as server:
if self._user and self._password: if self._user and self._password:
server.login(self._user, self._password) server.login(self._user, self._password)
server.sendmail(self._from, self._recipients, msg.as_string()) server.sendmail(self._from, self._recipients, msg.as_string())
async def test(self) -> tuple[bool, str]: async def test(self) -> tuple[bool, str]:
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
await loop.run_in_executor( await loop.run_in_executor(
None, None,
self._send_email, self._send_email,
"[MeshAI TEST] Notification Test", "[MeshAI TEST] Notification Test",
"Test message from MeshAI.", "Test message from MeshAI.",
) )
return True, "Test email sent to %d recipients" % len(self._recipients) return True, "Test email sent to %d recipients" % len(self._recipients)
except Exception as e: except Exception as e:
return False, "Failed to send test email: %s" % e return False, "Failed to send test email: %s" % e
class WebhookChannel(NotificationChannel): class WebhookChannel(NotificationChannel):
"""POST alert JSON to a URL.""" """POST alert JSON to a URL."""
channel_type = "webhook" channel_type = "webhook"
def __init__(self, url: str, headers: Optional[dict] = None): def __init__(self, url: str, headers: Optional[dict] = None):
self._url = url self._url = url
self._headers = headers or {} self._headers = headers or {}
async def deliver(self, alert: dict, rule: dict) -> bool: async def deliver(self, alert: dict, rule: dict) -> bool:
"""POST alert to webhook URL.""" """POST alert to webhook URL."""
payload = { payload = {
"type": alert.get("type"), "type": alert.get("type"),
"severity": alert.get("severity", "info"), "severity": alert.get("severity", "info"),
"message": alert.get("message", ""), "message": alert.get("message", ""),
"timestamp": time.time(), "timestamp": time.time(),
"node_name": alert.get("node_name"), "node_name": alert.get("node_name"),
"region": alert.get("region"), "region": alert.get("region"),
} }
# Discord/Slack format # Discord/Slack format
if "discord.com" in self._url or "slack.com" in self._url: if "discord.com" in self._url or "slack.com" in self._url:
severity = alert.get("severity", "info") severity = alert.get("severity", "info")
color = { color = {
"emergency": 0xFF0000, "emergency": 0xFF0000,
"critical": 0xFF4444, "critical": 0xFF4444,
"warning": 0xFFAA00, "warning": 0xFFAA00,
"info": 0x0099FF, "info": 0x0099FF,
}.get(severity, 0x888888) }.get(severity, 0x888888)
payload = { payload = {
"embeds": [{ "embeds": [{
"title": "MeshAI: %s" % alert.get("type", "unknown"), "title": "MeshAI: %s" % alert.get("type", "unknown"),
"description": alert.get("message", ""), "description": alert.get("message", ""),
"color": color, "color": color,
}] }]
} }
# ntfy format # ntfy format
elif "ntfy" in self._url: elif "ntfy" in self._url:
headers = { headers = {
**self._headers, **self._headers,
"Title": "MeshAI: %s" % alert.get("type", "alert"), "Title": "MeshAI: %s" % alert.get("type", "alert"),
"Priority": "3", "Priority": "3",
} }
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.post( resp = await client.post(
self._url, self._url,
content=alert.get("message", ""), content=alert.get("message", ""),
headers=headers, headers=headers,
timeout=10, timeout=10,
) )
return resp.status_code < 400 return resp.status_code < 400
except Exception as e: except Exception as e:
logger.error("Webhook failed: %s", e) logger.error("Webhook failed: %s", e)
return False return False
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.post( resp = await client.post(
self._url, self._url,
json=payload, json=payload,
headers={"Content-Type": "application/json", **self._headers}, headers={"Content-Type": "application/json", **self._headers},
timeout=10, timeout=10,
) )
return resp.status_code < 400 return resp.status_code < 400
except Exception as e: except Exception as e:
logger.error("Webhook failed: %s", e) logger.error("Webhook failed: %s", e)
return False return False
async def test(self) -> tuple[bool, str]: async def test(self) -> tuple[bool, str]:
test_alert = {"type": "test", "severity": "info", "message": "MeshAI test message"} test_alert = {"type": "test", "severity": "info", "message": "MeshAI test message"}
success = await self.deliver(test_alert, {}) success = await self.deliver(test_alert, {})
if success: if success:
return True, "Test sent to %s" % self._url return True, "Test sent to %s" % self._url
return False, "Webhook failed"
async def deliver_test(self, message: str) -> bool:
"""Deliver a specific test message via webhook."""
def create_channel(config: dict, connector=None) -> NotificationChannel: try:
"""Create a channel instance from config.""" test_alert = {"type": "test", "severity": "info", "message": message}
channel_type = config.get("type", "") return await self.deliver(test_alert, {})
except Exception as e:
if channel_type == "mesh_broadcast": logger.warning("Webhook test failed: %s", e)
return MeshBroadcastChannel( return False
connector=connector, return False, "Webhook failed"
channel_index=config.get("channel_index", 0),
)
elif channel_type == "mesh_dm": def create_channel(config: dict, connector=None) -> NotificationChannel:
return MeshDMChannel( """Create a channel instance from config."""
connector=connector, channel_type = config.get("type", "")
node_ids=config.get("node_ids", []),
) if channel_type == "mesh_broadcast":
elif channel_type == "email": return MeshBroadcastChannel(
return EmailChannel( connector=connector,
smtp_host=config.get("smtp_host", ""), channel_index=config.get("channel_index", 0),
smtp_port=config.get("smtp_port", 587), )
smtp_user=config.get("smtp_user", ""), elif channel_type == "mesh_dm":
smtp_password=config.get("smtp_password", ""), return MeshDMChannel(
smtp_tls=config.get("smtp_tls", True), connector=connector,
from_address=config.get("from_address", ""), node_ids=config.get("node_ids", []),
recipients=config.get("recipients", []), )
) elif channel_type == "email":
elif channel_type == "webhook": return EmailChannel(
return WebhookChannel( smtp_host=config.get("smtp_host", ""),
url=config.get("url", ""), smtp_port=config.get("smtp_port", 587),
headers=config.get("headers", {}), smtp_user=config.get("smtp_user", ""),
) smtp_password=config.get("smtp_password", ""),
else: smtp_tls=config.get("smtp_tls", True),
raise ValueError("Unknown channel type: %s" % channel_type) from_address=config.get("from_address", ""),
recipients=config.get("recipients", []),
)
elif channel_type == "webhook":
return WebhookChannel(
url=config.get("url", ""),
headers=config.get("headers", {}),
)
else:
raise ValueError("Unknown channel type: %s" % channel_type)

View file

@ -1,354 +1,560 @@
"""Notification router - matches alerts to rules and delivers via channels.""" """Notification router - matches alerts to rules and delivers via channels."""
import asyncio import asyncio
import logging import logging
import time import time
from datetime import datetime from datetime import datetime
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from .channels import create_channel, NotificationChannel from .channels import create_channel, NotificationChannel
from .summarizer import MessageSummarizer from .summarizer import MessageSummarizer
if TYPE_CHECKING: if TYPE_CHECKING:
from ..connector import MeshConnector from ..connector import MeshConnector
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Severity levels in order # Severity levels in order
SEVERITY_ORDER = ["info", "advisory", "watch", "warning", "critical", "emergency"] SEVERITY_ORDER = ["info", "advisory", "watch", "warning", "critical", "emergency"]
class NotificationRouter: class NotificationRouter:
"""Routes alerts through matching rules to notification channels.""" """Routes alerts through matching rules to notification channels."""
def __init__( def __init__(
self, self,
config, config,
connector: Optional["MeshConnector"] = None, connector: Optional["MeshConnector"] = None,
llm_backend=None, llm_backend=None,
timezone: str = "America/Boise", timezone: str = "America/Boise",
): ):
self._rules: list[dict] = [] self._rules: list[dict] = []
self._quiet_enabled = getattr(config, "quiet_hours_enabled", True) self._quiet_enabled = getattr(config, "quiet_hours_enabled", True)
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._recent: dict[tuple, float] = {} # (rule_name, 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._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
self._connector = connector self._connector = connector
self._config = config self._config = config
# Load rules from config # Load rules from config
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 = dict(rule) if isinstance(rule, dict) else {} rule_dict = dict(rule) if isinstance(rule, dict) else {}
# Skip disabled rules # Skip disabled rules
if not rule_dict.get("enabled", True): if not rule_dict.get("enabled", True):
continue continue
# Only load condition-triggered rules (scheduled rules handled by scheduler) # Only load condition-triggered rules (scheduled rules handled by scheduler)
if rule_dict.get("trigger_type", "condition") == "condition": if rule_dict.get("trigger_type", "condition") == "condition":
self._rules.append(rule_dict) self._rules.append(rule_dict)
logger.info("Notification router initialized: %d condition rules", len(self._rules)) logger.info("Notification router initialized: %d condition rules", len(self._rules))
def _create_channel_for_rule(self, rule: dict) -> Optional[NotificationChannel]: 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. Returns None if delivery_type is empty or invalid.
""" """
delivery_type = rule.get("delivery_type", "") delivery_type = rule.get("delivery_type", "")
# Empty delivery type is valid - rule exists but doesn't deliver # Empty delivery type is valid - rule exists but doesn't deliver
if not delivery_type: if not delivery_type:
return None return None
if delivery_type == "mesh_broadcast": if delivery_type == "mesh_broadcast":
config = { config = {
"type": "mesh_broadcast", "type": "mesh_broadcast",
"channel_index": rule.get("broadcast_channel", 0), "channel_index": rule.get("broadcast_channel", 0),
} }
elif delivery_type == "mesh_dm": elif delivery_type == "mesh_dm":
config = { config = {
"type": "mesh_dm", "type": "mesh_dm",
"node_ids": rule.get("node_ids", []), "node_ids": rule.get("node_ids", []),
} }
elif delivery_type == "email": elif delivery_type == "email":
config = { config = {
"type": "email", "type": "email",
"smtp_host": rule.get("smtp_host", ""), "smtp_host": rule.get("smtp_host", ""),
"smtp_port": rule.get("smtp_port", 587), "smtp_port": rule.get("smtp_port", 587),
"smtp_user": rule.get("smtp_user", ""), "smtp_user": rule.get("smtp_user", ""),
"smtp_password": rule.get("smtp_password", ""), "smtp_password": rule.get("smtp_password", ""),
"smtp_tls": rule.get("smtp_tls", True), "smtp_tls": rule.get("smtp_tls", True),
"from_address": rule.get("from_address", ""), "from_address": rule.get("from_address", ""),
"recipients": rule.get("recipients", []), "recipients": rule.get("recipients", []),
} }
elif delivery_type == "webhook": elif delivery_type == "webhook":
config = { config = {
"type": "webhook", "type": "webhook",
"url": rule.get("webhook_url", ""), "url": rule.get("webhook_url", ""),
"headers": rule.get("webhook_headers", {}), "headers": rule.get("webhook_headers", {}),
} }
else: else:
logger.warning("Unknown delivery type '%s' in rule '%s'", delivery_type, rule.get("name")) logger.warning("Unknown delivery type '%s' in rule '%s'", delivery_type, rule.get("name"))
return None return None
try: try:
return create_channel(config, self._connector) return create_channel(config, self._connector)
except Exception as e: 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 return None
async def process_alert(self, alert: dict) -> bool: async def process_alert(self, alert: dict) -> bool:
"""Route an alert through matching rules. """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.
""" """
category = alert.get("type", "") category = alert.get("type", "")
severity = alert.get("severity", "info") severity = alert.get("severity", "info")
delivered = False delivered = False
for rule in self._rules: for rule in self._rules:
rule_name = rule.get("name", "unnamed") rule_name = rule.get("name", "unnamed")
# Check category match # Check category match
rule_categories = rule.get("categories", []) rule_categories = rule.get("categories", [])
if rule_categories and category not in rule_categories: if rule_categories and category not in rule_categories:
continue continue
# Check severity threshold # Check severity threshold
min_severity = rule.get("min_severity", "info") min_severity = rule.get("min_severity", "info")
if not self._severity_meets(severity, min_severity): if not self._severity_meets(severity, min_severity):
continue continue
# Check quiet hours (only if quiet hours are enabled globally) # Check quiet hours (only if quiet hours are enabled globally)
if self._quiet_enabled and self._in_quiet_hours(): if self._quiet_enabled and self._in_quiet_hours():
# Emergencies and criticals always go through # Emergencies and criticals always go through
if severity not in ("emergency", "critical"): if severity not in ("emergency", "critical"):
# Check if rule overrides quiet hours # Check if rule overrides quiet hours
if not rule.get("override_quiet", False): if not rule.get("override_quiet", False):
logger.debug("Skipping alert (quiet hours): %s via %s", category, rule_name) logger.debug("Skipping alert (quiet hours): %s via %s", category, rule_name)
continue continue
# Check cooldown # Check cooldown
cooldown = rule.get("cooldown_minutes", 10) * 60 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 = (rule_name, category, event_id) 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] < cooldown: if now - self._recent[dedup_key] < cooldown:
logger.debug("Skipping alert (cooldown): %s via %s", category, rule_name) logger.debug("Skipping alert (cooldown): %s via %s", category, rule_name)
continue continue
self._recent[dedup_key] = now self._recent[dedup_key] = now
# Log rule match # Log rule match
logger.info("Rule '%s' matched alert: %s (%s)", rule_name, category, severity) logger.info("Rule '%s' matched alert: %s (%s)", rule_name, category, severity)
# Check if rule has delivery configured # Check if rule has delivery configured
delivery_type = rule.get("delivery_type", "") delivery_type = rule.get("delivery_type", "")
if not delivery_type: if not delivery_type:
logger.info("Rule '%s' matched but has no delivery configured", rule_name) logger.info("Rule '%s' matched but has no delivery configured", rule_name)
continue continue
# Create channel and deliver # Create channel and deliver
channel = self._create_channel_for_rule(rule) channel = self._create_channel_for_rule(rule)
if not channel: if not channel:
logger.warning("Rule '%s' failed to create delivery channel", rule_name) logger.warning("Rule '%s' failed to create delivery channel", rule_name)
continue 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("Alert delivered via rule '%s': %s", rule_name, category) logger.info("Alert delivered via rule '%s': %s", rule_name, category)
except Exception as e: 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 return delivered
def _severity_meets(self, actual: str, required: str) -> bool: def _severity_meets(self, actual: str, required: str) -> bool:
"""Check if actual severity meets or exceeds required severity.""" """Check if actual severity meets or exceeds required severity."""
try: try:
actual_idx = SEVERITY_ORDER.index(actual.lower()) actual_idx = SEVERITY_ORDER.index(actual.lower())
required_idx = SEVERITY_ORDER.index(required.lower()) required_idx = SEVERITY_ORDER.index(required.lower())
return actual_idx >= required_idx return actual_idx >= required_idx
except ValueError: except ValueError:
return True # Unknown severity, allow through return True # Unknown severity, allow through
def _in_quiet_hours(self) -> bool: def _in_quiet_hours(self) -> bool:
"""Check if current time is within quiet hours.""" """Check if current time is within quiet hours."""
if not self._quiet_enabled: if not self._quiet_enabled:
return False return False
try: try:
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
tz = ZoneInfo(self._timezone) tz = ZoneInfo(self._timezone)
now = datetime.now(tz) now = datetime.now(tz)
current_time = now.strftime("%H:%M") current_time = now.strftime("%H:%M")
start = self._quiet_start start = self._quiet_start
end = self._quiet_end end = self._quiet_end
if start <= end: if start <= end:
# Simple range (e.g., 01:00 to 06:00) # Simple range (e.g., 01:00 to 06:00)
return start <= current_time <= end return start <= current_time <= end
else: else:
# Crosses midnight (e.g., 22:00 to 06:00) # Crosses midnight (e.g., 22:00 to 06:00)
return current_time >= start or current_time <= end return current_time >= start or current_time <= end
except Exception: except Exception:
return False return False
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_rule(self, rule_index: int) -> tuple[bool, str]: async def test_rule(self, rule_index: int) -> tuple[bool, str]:
"""Send a test alert through a specific rule.""" """Send a test alert through a specific rule (legacy method)."""
rules_config = getattr(self._config, "rules", []) result = await self.test_rule_with_conditions(rule_index, send=True)
if rule_index < 0 or rule_index >= len(rules_config): return result.get("delivered", False), result.get("delivery_result", "Unknown")
return False, "Rule index out of range"
async def test_rule_with_conditions(
rule = rules_config[rule_index] self,
if hasattr(rule, "__dict__"): rule_index: int,
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")} alert_engine=None,
else: env_store=None,
rule_dict = dict(rule) send: bool = False,
) -> dict:
# Check if delivery is configured """Test a rule against current conditions.
if not rule_dict.get("delivery_type"):
return False, "No delivery method configured for this rule" Args:
rule_index: Index of the rule to test
channel = self._create_channel_for_rule(rule_dict) alert_engine: AlertEngine instance for pending alerts
if not channel: env_store: EnvStore instance for environmental events
return False, "Failed to create delivery channel" send: Whether to actually deliver (True) or just preview (False)
return await channel.test() Returns:
{
async def preview_rule(self, rule_index: int) -> dict: "conditions_matched": int,
"""Preview what a rule would match right now. "preview_messages": list[str],
"is_example": bool,
Returns: "delivered": bool,
{ "delivery_method": str,
"matches": bool, "delivery_result": str,
"conditions": [...], # Current conditions that match }
"preview": str, # Example message """
} from .categories import get_category
"""
rules_config = getattr(self._config, "rules", []) rules_config = getattr(self._config, "rules", [])
if rule_index < 0 or rule_index >= len(rules_config): if rule_index < 0 or rule_index >= len(rules_config):
return {"matches": False, "conditions": [], "preview": "Invalid rule index"} return {
"conditions_matched": 0,
rule = rules_config[rule_index] "preview_messages": [],
if hasattr(rule, "__dict__"): "is_example": False,
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")} "delivered": False,
else: "delivery_method": "",
rule_dict = dict(rule) "delivery_result": "Rule index out of range",
}
# For condition rules, show example based on categories
if rule_dict.get("trigger_type", "condition") == "condition": rule = rules_config[rule_index]
from .categories import get_category if hasattr(rule, "__dict__"):
categories = rule_dict.get("categories", []) rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
else:
if not categories: rule_dict = dict(rule)
# All categories - show first example
example = get_category("infra_offline") rule_categories = rule_dict.get("categories", [])
return { min_severity = rule_dict.get("min_severity", "info")
"matches": True, delivery_type = rule_dict.get("delivery_type", "")
"conditions": ["All alert categories"],
"preview": example.get("example_message", "Alert notification"), # Collect matching alerts from alert_engine
} matching_alerts = []
else:
# Show example from first category if alert_engine and hasattr(alert_engine, "get_pending_alerts"):
cat_info = get_category(categories[0]) try:
return { for alert in alert_engine.get_pending_alerts():
"matches": True, category = alert.get("type", "")
"conditions": [get_category(c)["name"] for c in categories], severity = alert.get("severity", "info")
"preview": cat_info.get("example_message", f"Alert: {categories[0]}"),
} # Check category match
if rule_categories and category not in rule_categories:
# For schedule rules, generate preview report continue
elif rule_dict.get("trigger_type") == "schedule":
message_type = rule_dict.get("message_type", "mesh_health_summary") # Check severity threshold
return { if not self._severity_meets(severity, min_severity):
"matches": True, continue
"conditions": [f"Scheduled: {rule_dict.get('schedule_frequency', 'daily')}"],
"preview": f"[{message_type}] Report content would appear here", matching_alerts.append(alert)
} except Exception as e:
logger.warning("Error getting pending alerts: %s", e)
return {"matches": False, "conditions": [], "preview": "Unknown rule type"}
# Collect matching env events
def add_mesh_subscription( if env_store and hasattr(env_store, "get_active"):
self, try:
node_id: str, # Map category prefixes to env sources
categories: list[str], source_map = {
rule_name: Optional[str] = None, "weather_": "nws",
) -> str: "fire_": "nifc",
"""Add a mesh DM subscription for a node. "wildfire_": "nifc",
"new_ignition": "firms",
Creates a rule for the node to receive alerts. "stream_": "usgs",
Returns the rule name. "road_": "here",
""" "traffic_": "here",
if not rule_name: "avalanche_": "avy",
rule_name = "sub_%s" % node_id "hf_blackout": "swpc",
"geomagnetic_": "swpc",
# Check if rule already exists "tropospheric_": "ducting",
for rule in self._rules: }
if rule.get("name") == rule_name:
# Update existing rule # Get all active events
rule["categories"] = categories if categories else [] all_events = env_store.get_active()
rule["node_ids"] = [node_id]
return rule_name for event in all_events:
event_type = event.get("type", "")
# Add new rule severity = event.get("severity", "info")
self._rules.append({
"name": rule_name, # Try to match to a category
"enabled": True, matched_category = None
"trigger_type": "condition", for cat in rule_categories if rule_categories else list(source_map.keys()):
"categories": categories if categories else [], # Empty = all if event_type.startswith(cat.rstrip("_")) or cat in event_type:
"min_severity": "warning", matched_category = cat
"delivery_type": "mesh_dm", break
"node_ids": [node_id],
"cooldown_minutes": 10, if rule_categories and not matched_category:
"override_quiet": False, continue
})
# Check severity
return rule_name if not self._severity_meets(severity, min_severity):
continue
def remove_mesh_subscription(self, node_id: str) -> bool:
"""Remove a mesh subscription for a node.""" # Convert to alert format
rule_name = "sub_%s" % node_id matching_alerts.append({
self._rules = [r for r in self._rules if r.get("name") != rule_name] "type": event_type,
return True "severity": severity,
"message": event.get("message", event.get("summary", str(event))),
def get_node_subscriptions(self, node_id: str) -> list[str]: })
"""Get categories a node is subscribed to.""" except Exception as e:
rule_name = "sub_%s" % node_id logger.warning("Error getting env events: %s", e)
for rule in self._rules:
if rule.get("name") == rule_name: # Build preview messages
categories = rule.get("categories", []) preview_messages = []
return categories if categories else ["all"] is_example = False
return []
if matching_alerts:
def cleanup_recent(self, max_age: int = 3600): # Use real alerts
"""Clean up old entries from recent alerts cache.""" for alert in matching_alerts[:5]: # Limit to 5
now = time.time() msg = alert.get("message", "")
self._recent = { if len(msg) > 200 and delivery_type in ("mesh_broadcast", "mesh_dm"):
k: v for k, v in self._recent.items() # Truncate for mesh delivery preview
if now - v < max_age msg = msg[:195] + "..."
} preview_messages.append(msg)
else:
# No matches - use example messages
is_example = True
if rule_categories:
for cat_id in rule_categories[:3]:
cat_info = get_category(cat_id)
example = cat_info.get("example_message", f"Alert: {cat_id}")
preview_messages.append(f"[EXAMPLE] {example}")
else:
# Rule matches all categories - show generic example
cat_info = get_category("infra_offline")
preview_messages.append(f"[EXAMPLE] {cat_info.get('example_message', 'Alert notification')}")
# Check if delivery is configured
if not delivery_type:
return {
"conditions_matched": len(matching_alerts),
"preview_messages": preview_messages,
"is_example": is_example,
"delivered": False,
"delivery_method": "",
"delivery_result": "No delivery method configured for this rule",
}
# Create channel
channel = self._create_channel_for_rule(rule_dict)
if not channel:
return {
"conditions_matched": len(matching_alerts),
"preview_messages": preview_messages,
"is_example": is_example,
"delivered": False,
"delivery_method": delivery_type,
"delivery_result": "Failed to create delivery channel",
}
# If not sending, just return preview
if not send:
return {
"conditions_matched": len(matching_alerts),
"preview_messages": preview_messages,
"is_example": is_example,
"delivered": False,
"delivery_method": delivery_type,
"delivery_result": "Preview only - use send=true to deliver",
}
# Actually send the test message
try:
# Pick the first message to send
if preview_messages:
test_message = preview_messages[0]
if not test_message.startswith("["):
test_message = f"[TEST] {test_message}"
elif test_message.startswith("[EXAMPLE]"):
test_message = test_message.replace("[EXAMPLE]", "[TEST]")
else:
test_message = "[TEST] MeshAI notification test"
# Send through channel with the real message
success = await channel.deliver_test(test_message)
if success:
delivery_result = f"Sent to {delivery_type}"
if delivery_type == "mesh_broadcast":
delivery_result = f"Sent to channel {rule_dict.get('broadcast_channel', 0)}"
elif delivery_type == "mesh_dm":
node_count = len(rule_dict.get("node_ids", []))
delivery_result = f"Sent DM to {node_count} node(s)"
elif delivery_type == "email":
recipient_count = len(rule_dict.get("recipients", []))
delivery_result = f"Sent to {recipient_count} recipient(s)"
else:
delivery_result = "Delivery failed"
return {
"conditions_matched": len(matching_alerts),
"preview_messages": preview_messages,
"is_example": is_example,
"delivered": success,
"delivery_method": delivery_type,
"delivery_result": delivery_result,
}
except Exception as e:
logger.warning("Test delivery failed: %s", e)
return {
"conditions_matched": len(matching_alerts),
"preview_messages": preview_messages,
"is_example": is_example,
"delivered": False,
"delivery_method": delivery_type,
"delivery_result": f"Delivery error: {e}",
}
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,
categories: list[str],
rule_name: Optional[str] = None,
) -> str:
"""Add a mesh DM subscription for a node.
Creates a rule for the node to receive alerts.
Returns the rule name.
"""
if not rule_name:
rule_name = "sub_%s" % node_id
# Check if rule already exists
for rule in self._rules:
if rule.get("name") == rule_name:
# Update existing rule
rule["categories"] = categories if categories else []
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",
"delivery_type": "mesh_dm",
"node_ids": [node_id],
"cooldown_minutes": 10,
"override_quiet": False,
})
return rule_name
def remove_mesh_subscription(self, node_id: str) -> bool:
"""Remove a mesh subscription for a node."""
rule_name = "sub_%s" % node_id
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]:
"""Get categories a node is subscribed to."""
rule_name = "sub_%s" % node_id
for rule in self._rules:
if rule.get("name") == rule_name:
categories = rule.get("categories", [])
return categories if categories else ["all"]
return []
def cleanup_recent(self, max_age: int = 3600):
"""Clean up old entries from recent alerts cache."""
now = time.time()
self._recent = {
k: v for k, v in self._recent.items()
if now - v < max_age
}