mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
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:
parent
c6b4a64163
commit
0ad37e55d9
8 changed files with 1198 additions and 816 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
1
meshai/dashboard/static/assets/index-CtFYHJy4.css
Normal file
1
meshai/dashboard/static/assets/index-CtFYHJy4.css
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.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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue