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 [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>
|
||||
|
|
|
|||
|
|
@ -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
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.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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue