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 [error, setError] = 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 [hasChanges, setHasChanges] = useState(false)
@ -1223,17 +1233,46 @@ export default function Notifications() {
}
const testRule = async (index: number) => {
// Open dialog and show preview first
setTestDialog({ open: true, ruleIndex: index, loading: true })
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()
setTestResult(result)
setTimeout(() => setTestResult(null), 5000)
setTestDialog(d => ({ ...d, loading: false }))
} catch {
setTestResult({ success: false, message: 'Test failed' })
setTimeout(() => setTestResult(null), 5000)
setTestResult({ success: false, message: 'Failed to get preview' })
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) {
return (
<div className="flex items-center justify-center h-64">
@ -1252,6 +1291,85 @@ export default function Notifications() {
return (
<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 */}
<div className="flex items-center justify-between">
<div>

View file

@ -1,10 +1,17 @@
"""Notification API routes."""
from fastapi import APIRouter, Request, HTTPException
from pydantic import BaseModel
from typing import Optional
router = APIRouter(prefix="/notifications", tags=["notifications"])
class TestRequest(BaseModel):
"""Request body for test endpoint."""
send: bool = False # True = actually deliver, False = preview only
@router.get("/categories")
async def get_categories():
"""Get all alert categories with descriptions."""
@ -25,11 +32,53 @@ async def get_rules(request: Request):
@router.post("/rules/{rule_index}/test")
async def test_rule(request: Request, rule_index: int):
"""Send a test alert through a specific rule."""
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")
success, message = await notification_router.test_rule(rule_index)
return {"success": success, "message": message}
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.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-CYCOCObI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DbmGQdf0.css">
<script type="module" crossorigin src="/assets/index-BXyt_EfK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CtFYHJy4.css">
</head>
<body>
<div id="root"></div>

View file

@ -272,6 +272,15 @@ class WebhookChannel(NotificationChannel):
success = await self.deliver(test_alert, {})
if success:
return True, "Test sent to %s" % self._url
async def deliver_test(self, message: str) -> bool:
"""Deliver a specific test message via webhook."""
try:
test_alert = {"type": "test", "severity": "info", "message": message}
return await self.deliver(test_alert, {})
except Exception as e:
logger.warning("Webhook test failed: %s", e)
return False
return False, "Webhook failed"

View file

@ -219,10 +219,47 @@ class NotificationRouter:
return self._rules
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)."""
result = await self.test_rule_with_conditions(rule_index, send=True)
return result.get("delivered", False), result.get("delivery_result", "Unknown")
async def test_rule_with_conditions(
self,
rule_index: int,
alert_engine=None,
env_store=None,
send: bool = False,
) -> dict:
"""Test a rule against current conditions.
Args:
rule_index: Index of the rule to test
alert_engine: AlertEngine instance for pending alerts
env_store: EnvStore instance for environmental events
send: Whether to actually deliver (True) or just preview (False)
Returns:
{
"conditions_matched": int,
"preview_messages": list[str],
"is_example": bool,
"delivered": bool,
"delivery_method": str,
"delivery_result": str,
}
"""
from .categories import get_category
rules_config = getattr(self._config, "rules", [])
if rule_index < 0 or rule_index >= len(rules_config):
return False, "Rule index out of range"
return {
"conditions_matched": 0,
"preview_messages": [],
"is_example": False,
"delivered": False,
"delivery_method": "",
"delivery_result": "Rule index out of range",
}
rule = rules_config[rule_index]
if hasattr(rule, "__dict__"):
@ -230,15 +267,184 @@ class NotificationRouter:
else:
rule_dict = dict(rule)
# Check if delivery is configured
if not rule_dict.get("delivery_type"):
return False, "No delivery method configured for this rule"
rule_categories = rule_dict.get("categories", [])
min_severity = rule_dict.get("min_severity", "info")
delivery_type = rule_dict.get("delivery_type", "")
# Collect matching alerts from alert_engine
matching_alerts = []
if alert_engine and hasattr(alert_engine, "get_pending_alerts"):
try:
for alert in alert_engine.get_pending_alerts():
category = alert.get("type", "")
severity = alert.get("severity", "info")
# Check category match
if rule_categories and category not in rule_categories:
continue
# Check severity threshold
if not self._severity_meets(severity, min_severity):
continue
matching_alerts.append(alert)
except Exception as e:
logger.warning("Error getting pending alerts: %s", e)
# Collect matching env events
if env_store and hasattr(env_store, "get_active"):
try:
# Map category prefixes to env sources
source_map = {
"weather_": "nws",
"fire_": "nifc",
"wildfire_": "nifc",
"new_ignition": "firms",
"stream_": "usgs",
"road_": "here",
"traffic_": "here",
"avalanche_": "avy",
"hf_blackout": "swpc",
"geomagnetic_": "swpc",
"tropospheric_": "ducting",
}
# Get all active events
all_events = env_store.get_active()
for event in all_events:
event_type = event.get("type", "")
severity = event.get("severity", "info")
# Try to match to a category
matched_category = None
for cat in rule_categories if rule_categories else list(source_map.keys()):
if event_type.startswith(cat.rstrip("_")) or cat in event_type:
matched_category = cat
break
if rule_categories and not matched_category:
continue
# Check severity
if not self._severity_meets(severity, min_severity):
continue
# Convert to alert format
matching_alerts.append({
"type": event_type,
"severity": severity,
"message": event.get("message", event.get("summary", str(event))),
})
except Exception as e:
logger.warning("Error getting env events: %s", e)
# Build preview messages
preview_messages = []
is_example = False
if matching_alerts:
# Use real alerts
for alert in matching_alerts[:5]: # Limit to 5
msg = alert.get("message", "")
if len(msg) > 200 and delivery_type in ("mesh_broadcast", "mesh_dm"):
# Truncate for mesh delivery preview
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 False, "Failed to create delivery 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",
}
return await channel.test()
# 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.