Files
drone-detector/client/src/pages/Alerts.jsx
2025-09-23 10:23:44 +02:00

1473 lines
61 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import api from '../services/api';
import { format } from 'date-fns';
import { useTranslation } from '../utils/tempTranslations';
import {
PlusIcon,
BellIcon,
CheckCircleIcon,
XCircleIcon,
ExclamationTriangleIcon,
ChevronDownIcon,
ChevronRightIcon,
XMarkIcon
} from '@heroicons/react/24/outline';
import { EditAlertModal, DetectionDetailsModal } from '../components/AlertModals';
import { useDroneTypes } from '../hooks/useDroneTypes';
const Alerts = () => {
const { t } = useTranslation();
// Drone types hook for dynamic drone type data
const { getDroneTypeInfo: getDroneTypeInfoFromAPI, loading: droneTypesLoading } = useDroneTypes();
const [alertRules, setAlertRules] = useState([]);
const [alertLogs, setAlertLogs] = useState([]);
const [alertStats, setAlertStats] = useState(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('rules');
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [editingRule, setEditingRule] = useState(null);
const [showDetectionModal, setShowDetectionModal] = useState(false);
const [selectedDetection, setSelectedDetection] = useState(null);
const [showAlertDetailsModal, setShowAlertDetailsModal] = useState(false);
const [selectedAlertForDetails, setSelectedAlertForDetails] = useState(null);
const [expandedGroups, setExpandedGroups] = useState(new Set());
useEffect(() => {
fetchAlertData();
}, []);
const fetchAlertData = async () => {
try {
const [rulesRes, logsRes, statsRes] = await Promise.all([
api.get('/alerts/rules'),
api.get('/alerts/logs?limit=50'),
api.get('/alerts/stats?hours=24')
]);
setAlertRules(rulesRes.data?.data || []);
setAlertLogs(logsRes.data?.data || []);
setAlertStats(statsRes.data?.data || null);
} catch (error) {
console.error('Error fetching alert data:', error);
// Set default values on error
setAlertRules([]);
setAlertLogs([]);
setAlertStats(null);
} finally {
setLoading(false);
}
};
// Show loading if either alerts or drone types are loading
const isLoading = loading || droneTypesLoading;
// DEBUG: Validate state integrity
useEffect(() => {
console.log('DEBUG: Alerts state check', {
alertRules: alertRules?.length,
alertLogs: alertLogs?.length,
alertStats: alertStats ? 'present' : 'null',
invalidRules: alertRules?.filter(rule => typeof rule.alert_channels === 'object' && !Array.isArray(rule.alert_channels))
});
}, [alertRules, alertLogs, alertStats]);
// Group alerts by alert_event_id to show related alerts together
const groupAlertsByEvent = (logs) => {
const grouped = {};
const ungrouped = [];
logs.forEach(log => {
if (log.alert_event_id) {
if (!grouped[log.alert_event_id]) {
grouped[log.alert_event_id] = [];
}
grouped[log.alert_event_id].push(log);
} else {
ungrouped.push(log);
}
});
// Convert grouped object to array of arrays, sorted by most recent alert in each group
const groupedArrays = Object.values(grouped).map(group =>
group.sort((a, b) => new Date(b.sent_at) - new Date(a.sent_at))
).sort((a, b) => new Date(b[0].sent_at) - new Date(a[0].sent_at));
// Add ungrouped alerts as individual groups
ungrouped.forEach(log => groupedArrays.push([log]));
return groupedArrays;
};
const toggleGroupExpansion = (groupIndex) => {
const newExpanded = new Set(expandedGroups);
if (newExpanded.has(groupIndex)) {
newExpanded.delete(groupIndex);
} else {
newExpanded.add(groupIndex);
}
setExpandedGroups(newExpanded);
};
// Get drone type information with visual styling (now using dynamic API data)
const getDroneTypeInfo = (detection) => {
if (!detection || detection.drone_type === undefined) {
return { name: 'Unknown', color: 'gray', bgColor: 'bg-gray-100', textColor: 'text-gray-600', icon: '🔍' };
}
// Use the dynamic API data from the hook
return getDroneTypeInfoFromAPI(detection.drone_type);
};
const handleDeleteRule = async (ruleId) => {
if (window.confirm(t('alerts.deleteRule') + '?')) {
try {
await api.delete(`/alerts/rules/${ruleId}`);
fetchAlertData();
} catch (error) {
console.error('Error deleting alert rule:', error);
}
}
};
const handleEditRule = (rule) => {
setEditingRule(rule);
setShowEditModal(true);
};
const handleViewDetection = async (detectionId) => {
try {
const response = await api.get(`/detections/${detectionId}`);
setSelectedDetection(response.data.data);
setShowDetectionModal(true);
} catch (error) {
console.error('Error fetching detection details:', error);
}
};
// Parse error message JSON to extract meaningful information
const parseErrorMessage = (errorMessage) => {
if (!errorMessage) return null;
try {
// Check if it's a success message
if (errorMessage.startsWith('Success:')) {
const jsonPart = errorMessage.substring(8).trim();
const successData = JSON.parse(jsonPart);
return {
type: 'success',
data: successData
};
}
// Check if it's a webhook network error
if (errorMessage.startsWith('Webhook network error:')) {
const jsonPart = errorMessage.substring(22).trim();
const errorData = JSON.parse(jsonPart);
return {
type: 'webhook_error',
data: errorData
};
}
// Check if it's an SMS error
if (errorMessage.startsWith('SMS error:')) {
const jsonPart = errorMessage.substring(10).trim();
const errorData = JSON.parse(jsonPart);
return {
type: 'sms_error',
data: errorData
};
}
// Check if it's an email error
if (errorMessage.startsWith('Email error:')) {
const jsonPart = errorMessage.substring(12).trim();
const errorData = JSON.parse(jsonPart);
return {
type: 'email_error',
data: errorData
};
}
// Try to parse as direct JSON
const parsed = JSON.parse(errorMessage);
return {
type: 'parsed_json',
data: parsed
};
} catch (error) {
// If parsing fails, return the raw message
return {
type: 'raw',
data: { message: errorMessage }
};
}
};
const handleViewAlertDetails = (alert) => {
setSelectedAlertForDetails(alert);
setShowAlertDetailsModal(true);
};
const getStatusIcon = (status) => {
switch (status) {
case 'sent':
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
case 'failed':
return <XCircleIcon className="h-5 w-5 text-red-500" />;
case 'pending':
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />;
default:
return <BellIcon className="h-5 w-5 text-gray-500" />;
}
};
const getPriorityColor = (priority) => {
switch (priority) {
case 'critical':
return 'bg-red-100 text-red-800';
case 'high':
return 'bg-orange-100 text-orange-800';
case 'medium':
return 'bg-yellow-100 text-yellow-800';
case 'low':
return 'bg-green-100 text-green-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
<span className="ml-4 text-gray-600">
{droneTypesLoading ? t('common.loading') : t('alerts.loading')}
</span>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">
{t('alerts.title')}
</h3>
<p className="mt-1 text-sm text-gray-500">
{t('alerts.description')}
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="btn btn-primary flex items-center space-x-2"
>
<PlusIcon className="h-4 w-4" />
<span>{t('alerts.createAlert')}</span>
</button>
</div>
{/* Alert Stats */}
{alertStats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-lg shadow border">
<div className="text-2xl font-bold text-gray-900">{alertStats.total_alerts}</div>
<div className="text-sm text-gray-500">{t('alerts.totalAlerts24h')}</div>
</div>
<div className="bg-white p-4 rounded-lg shadow border">
<div className="text-2xl font-bold text-green-600">{alertStats.sent_alerts}</div>
<div className="text-sm text-gray-500">{t('alerts.sentSuccessfully')}</div>
</div>
<div className="bg-white p-4 rounded-lg shadow border">
<div className="text-2xl font-bold text-red-600">{alertStats.failed_alerts}</div>
<div className="text-sm text-gray-500">{t('alerts.failed')}</div>
</div>
<div className="bg-white p-4 rounded-lg shadow border">
<div className="text-2xl font-bold text-yellow-600">{alertStats.pending_alerts}</div>
<div className="text-sm text-gray-500">{t('alerts.pending')}</div>
</div>
</div>
)}
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('rules')}
className={`whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'rules'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{t('alerts.alertRules')} ({alertRules.length})
</button>
<button
onClick={() => setActiveTab('logs')}
className={`whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'logs'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{t('alerts.alertLogs')} ({alertLogs?.length || 0})
</button>
</nav>
</div>
{/* Alert Rules Tab */}
{activeTab === 'rules' && (
<div className="bg-white rounded-lg shadow overflow-hidden">
{(alertRules?.length || 0) === 0 ? (
<div className="text-center py-12">
<BellIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('alerts.noAlertRules')}</h3>
<p className="mt-1 text-sm text-gray-500">
{t('alerts.noAlertRulesDescription')}
</p>
<div className="mt-6">
<button
onClick={() => setShowCreateModal(true)}
className="btn btn-primary"
>
<PlusIcon className="h-4 w-4 mr-2" />
{t('alerts.createAlertRule')}
</button>
</div>
</div>
) : (
<div className="table-responsive">
<table className="table">
<thead>
<tr>
<th>{t('alerts.name')}</th>
<th>{t('alerts.priority')}</th>
<th>{t('alerts.channels')}</th>
<th>{t('alerts.conditions')}</th>
<th>{t('alerts.status')}</th>
<th>{t('alerts.created')}</th>
<th>{t('alerts.actions')}</th>
</tr>
</thead>
<tbody>
{(alertRules || []).map((rule) => (
<tr key={rule.id} className="hover:bg-gray-50">
<td>
<div>
<div className="text-sm font-medium text-gray-900">
{rule.name}
</div>
{rule.description && (
<div className="text-sm text-gray-500">
{rule.description}
</div>
)}
</div>
</td>
<td>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
getPriorityColor(rule.priority)
}`}>
{(() => {
// DEBUG: Check if priority is an object
if (typeof rule.priority === 'object' && rule.priority !== null) {
console.error('DEBUG: Priority is an object:', rule.priority, 'for rule:', rule.id);
return 'Invalid Priority';
}
return t(`alerts.${rule.priority}`);
})()}
</span>
</td>
<td>
<div className="flex space-x-1">
{(() => {
// Normalize alert_channels - ensure it's always an array
let alertChannels = rule.alert_channels || [];
// DEBUG: Add debugging to catch object rendering
if (typeof alertChannels === 'object' && alertChannels !== null) {
const hasKeys = Object.keys(alertChannels).some(key => ['sms', 'webhook', 'email'].includes(key));
if (hasKeys) {
console.log('DEBUG: Found problematic alert_channels object:', alertChannels, 'for rule:', rule.id);
}
}
if (typeof alertChannels === 'object' && !Array.isArray(alertChannels)) {
// Convert object like {sms: true, webhook: false, email: true} to array
alertChannels = Object.keys(alertChannels).filter(key => alertChannels[key]);
}
if (!Array.isArray(alertChannels)) {
alertChannels = []; // fallback to empty array
}
// DEBUG: Ensure we're only rendering strings
return alertChannels.map((channel, index) => {
if (typeof channel !== 'string') {
console.error('DEBUG: Non-string channel detected:', channel, typeof channel);
return null;
}
return (
<span
key={index}
className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs"
>
{channel}
</span>
);
}).filter(Boolean);
})()}
</div>
</td>
<td>
<div className="text-sm text-gray-900">
{rule.min_detections > 1 && (
<div>{t('alerts.minDetections')}: {rule.min_detections}</div>
)}
{rule.time_window && (
<div>{t('alerts.timeWindow')}: {rule.time_window}s</div>
)}
{rule.cooldown_period && (
<div>{t('alerts.cooldown')}: {rule.cooldown_period}s</div>
)}
{rule.min_threat_level && (
<div>{t('alerts.minThreat')}: {rule.min_threat_level}</div>
)}
{rule.drone_types && rule.drone_types.length > 0 && (
<div className="mt-1">
<div className="text-xs text-gray-500">{t('alerts.droneTypes')}:</div>
<div className="flex flex-wrap gap-1 mt-1">
{rule.drone_types.map((typeId, index) => {
// Use the dynamic API data instead of hardcoded mapping
const droneInfo = getDroneTypeInfoFromAPI(typeId);
return (
<span
key={index}
className={`px-1.5 py-0.5 rounded text-xs font-medium ${
droneInfo.warning
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{droneInfo.name}
{droneInfo.warning && '⚠️'}
</span>
);
})}
</div>
</div>
)}
</div>
</td>
<td>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
rule.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{rule.is_active ? t('alerts.active') : t('alerts.inactive')}
</span>
</td>
<td>
<div className="text-sm text-gray-900">
{format(new Date(rule.created_at), 'MMM dd, yyyy')}
</div>
</td>
<td>
<div className="flex space-x-2">
<button
onClick={() => handleEditRule(rule)}
className="text-primary-600 hover:text-primary-900 text-sm"
>
{t('alerts.edit')}
</button>
<button
onClick={() => handleDeleteRule(rule.id)}
className="text-red-600 hover:text-red-900 text-sm"
>
{t('alerts.delete')}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Alert Logs Tab */}
{activeTab === 'logs' && (
<div className="bg-white rounded-lg shadow overflow-hidden">
{(alertLogs?.length || 0) === 0 ? (
<div className="text-center py-12">
<BellIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('alerts.noAlertLogs')}</h3>
<p className="mt-1 text-sm text-gray-500">
{t('alerts.noAlertLogsDescription')}
</p>
</div>
) : (
<div>
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-sm text-blue-700">
💡 <strong>Alert Grouping:</strong> Related alerts (SMS, email, webhook) for the same detection event are grouped together.
Click the expand button () or "+X more" badge to see all alerts in a group.
</p>
</div>
<div className="table-responsive">
<table className="table">
<thead>
<tr>
<th>{t('alerts.status')}</th>
<th>{t('alerts.type')}</th>
<th>{t('alerts.recipient')}</th>
<th>{t('alerts.rule')}</th>
<th>Drone Type</th>
<th>{t('alerts.droneId')}</th>
<th>{t('alerts.detection')}</th>
<th>Alert Details</th>
<th>{t('alerts.message')}</th>
<th>{t('alerts.sentAt')}</th>
<th>Event ID</th>
</tr>
</thead>
<tbody>
{groupAlertsByEvent(alertLogs || []).map((alertGroup, groupIndex) => {
// Display the primary alert (first in group)
const primaryAlert = alertGroup[0];
const relatedAlerts = alertGroup.slice(1);
return (
<React.Fragment key={`group-${groupIndex}`}>
<tr className="hover:bg-gray-50 border-b-2 border-gray-200">
<td>
<div className="flex items-center space-x-2">
{relatedAlerts.length > 0 && (
<button
onClick={() => toggleGroupExpansion(groupIndex)}
className="p-1 rounded hover:bg-gray-200 transition-colors"
title={expandedGroups.has(groupIndex) ? 'Collapse group' : 'Expand group'}
>
{expandedGroups.has(groupIndex) ? (
<ChevronDownIcon className="h-4 w-4 text-gray-500" />
) : (
<ChevronRightIcon className="h-4 w-4 text-gray-500" />
)}
</button>
)}
{getStatusIcon(primaryAlert.status)}
<span className="text-sm text-gray-900 capitalize">
{primaryAlert.status}
</span>
{relatedAlerts.length > 0 && (
<button
onClick={() => toggleGroupExpansion(groupIndex)}
className="ml-2 px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs hover:bg-blue-200 transition-colors cursor-pointer"
>
+{relatedAlerts.length} more
</button>
)}
</div>
</td>
<td>
<div className="flex flex-wrap gap-1">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">
{primaryAlert.alert_type}
</span>
{!expandedGroups.has(groupIndex) && relatedAlerts.map((alert, idx) => (
<span key={idx} className="px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">
{alert.alert_type}
</span>
))}
</div>
</td>
<td>
<div className="text-sm text-gray-900">
{primaryAlert.recipient}
{!expandedGroups.has(groupIndex) && relatedAlerts.length > 0 && relatedAlerts.some(a => a.recipient !== primaryAlert.recipient) && (
<div className="text-xs text-gray-500 mt-1">
+{relatedAlerts.filter(a => a.recipient !== primaryAlert.recipient).length} others
</div>
)}
</div>
</td>
<td>
<div className="text-sm text-gray-900">
{primaryAlert.rule?.name || t('alerts.unknownRule')}
</div>
</td>
<td>
{(() => {
const droneTypeInfo = getDroneTypeInfo(primaryAlert.detection);
return (
<div className="flex items-center space-x-2">
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${droneTypeInfo.bgColor} ${droneTypeInfo.textColor} border-2 border-${droneTypeInfo.color}-300`}>
{droneTypeInfo.icon} {droneTypeInfo.name}
</span>
{droneTypeInfo.warning && (
<span className="text-red-600 font-bold text-xs">{droneTypeInfo.warning}</span>
)}
</div>
);
})()}
</td>
<td>
<div className="text-sm text-gray-900">
{primaryAlert.detection?.drone_id ? (
<span className="px-2 py-1 bg-purple-100 text-purple-800 rounded text-xs font-mono">
{primaryAlert.detection.drone_id}
</span>
) : (
<span className="text-gray-400 text-sm">{t('alerts.na')}</span>
)}
</div>
</td>
<td>
{primaryAlert.detection_id ? (
<button
onClick={() => handleViewDetection(primaryAlert.detection_id)}
className="text-blue-600 hover:text-blue-800 text-sm underline"
>
{t('alerts.viewDetails')}
</button>
) : (
<span className="text-gray-400 text-sm">{t('alerts.na')}</span>
)}
</td>
<td>
<button
onClick={() => handleViewAlertDetails(primaryAlert)}
className="text-purple-600 hover:text-purple-800 text-sm underline"
>
Visa detaljer
</button>
</td>
<td>
<div className="text-sm text-gray-900 max-w-xs overflow-hidden">
<div className="truncate" title={primaryAlert.message}>
{primaryAlert.message}
</div>
</div>
</td>
<td>
<div className="text-sm text-gray-900">
{format(new Date(primaryAlert.sent_at), 'MMM dd, HH:mm')}
</div>
</td>
<td>
<div className="text-xs text-gray-500 font-mono">
{primaryAlert.alert_event_id ? (
<span title={primaryAlert.alert_event_id}>
{primaryAlert.alert_event_id.substring(0, 8)}...
</span>
) : (
<span className="text-gray-400">-</span>
)}
</div>
</td>
</tr>
{/* Show related alerts as sub-rows if expanded */}
{expandedGroups.has(groupIndex) && relatedAlerts.map((alert, alertIndex) => (
<tr key={`related-${groupIndex}-${alertIndex}`} className="bg-gray-50 border-l-4 border-blue-200">
<td className="pl-8">
<div className="flex items-center space-x-2">
{getStatusIcon(alert.status)}
<span className="text-sm text-gray-700 capitalize">
{alert.status}
</span>
</div>
</td>
<td>
<span className="px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">
{alert.alert_type}
</span>
</td>
<td>
<div className="text-sm text-gray-700">
{alert.recipient}
</div>
</td>
<td>
<div className="text-sm text-gray-700">
{alert.rule?.name || t('alerts.unknownRule')}
</div>
</td>
<td>
{(() => {
const droneTypeInfo = getDroneTypeInfo(alert.detection);
return (
<div className="flex items-center space-x-1">
<span className={`px-2 py-1 rounded text-xs font-medium ${droneTypeInfo.bgColor} ${droneTypeInfo.textColor} opacity-80`}>
{droneTypeInfo.icon} {droneTypeInfo.name}
</span>
{droneTypeInfo.warning && (
<span className="text-red-600 font-bold text-xs opacity-80">{droneTypeInfo.warning}</span>
)}
</div>
);
})()}
</td>
<td>
<div className="text-sm text-gray-700">
{alert.detection?.drone_id ? (
<span className="px-2 py-1 bg-purple-50 text-purple-600 rounded text-xs font-mono">
{alert.detection.drone_id}
</span>
) : (
<span className="text-gray-400 text-sm">{t('alerts.na')}</span>
)}
</div>
</td>
<td>
{alert.detection_id ? (
<button
onClick={() => handleViewDetection(alert.detection_id)}
className="text-blue-500 hover:text-blue-700 text-sm underline"
>
{t('alerts.viewDetails')}
</button>
) : (
<span className="text-gray-400 text-sm">{t('alerts.na')}</span>
)}
</td>
<td>
<button
onClick={() => handleViewAlertDetails(alert)}
className="text-purple-500 hover:text-purple-700 text-sm underline"
>
Visa detaljer
</button>
</td>
<td>
<div className="text-sm text-gray-700 max-w-xs overflow-hidden">
<div className="truncate" title={alert.message}>
{alert.message}
</div>
</div>
</td>
<td>
<div className="text-sm text-gray-700">
{format(new Date(alert.sent_at), 'MMM dd, HH:mm')}
</div>
</td>
<td>
<div className="text-xs text-gray-400 font-mono">
{alert.alert_event_id ? (
<span title={alert.alert_event_id}>
{alert.alert_event_id.substring(0, 8)}...
</span>
) : (
<span>-</span>
)}
</div>
</td>
</tr>
))}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
{/* Create Alert Rule Modal */}
{showCreateModal && (
<CreateAlertRuleModal
onClose={() => setShowCreateModal(false)}
onSave={() => {
setShowCreateModal(false);
fetchAlertData();
}}
/>
)}
{showEditModal && editingRule && (
<EditAlertModal
rule={editingRule}
onClose={() => {
setShowEditModal(false);
setEditingRule(null);
}}
onSuccess={fetchAlertData}
/>
)}
{showDetectionModal && selectedDetection && (
<DetectionDetailsModal
detection={selectedDetection}
onClose={() => {
setShowDetectionModal(false);
setSelectedDetection(null);
}}
/>
)}
{showAlertDetailsModal && selectedAlertForDetails && (
<AlertDetailsModal
alert={selectedAlertForDetails}
parseErrorMessage={parseErrorMessage}
onClose={() => {
setShowAlertDetailsModal(false);
setSelectedAlertForDetails(null);
}}
/>
)}
</div>
);
};
const CreateAlertRuleModal = ({ onClose, onSave }) => {
const [formData, setFormData] = useState({
name: '',
description: '',
priority: 'medium',
alert_channels: ['sms'],
min_detections: 1,
time_window: 300,
cooldown_period: 600,
device_ids: [],
drone_types: [],
min_rssi: '',
max_rssi: '',
sms_phone_number: '',
webhook_url: ''
});
const [saving, setSaving] = useState(false);
const [devices, setDevices] = useState([]);
const [droneTypes, setDroneTypes] = useState([]);
const [loadingData, setLoadingData] = useState(true);
useEffect(() => {
fetchDevicesAndDroneTypes();
}, []);
const fetchDevicesAndDroneTypes = async () => {
try {
const [devicesResponse, droneTypesResponse] = await Promise.all([
api.get('/devices'),
api.get('/drone-types')
]);
setDevices(devicesResponse.data.data || []);
setDroneTypes(droneTypesResponse.data.data || []);
} catch (error) {
console.error('Error fetching devices and drone types:', error);
} finally {
setLoadingData(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
try {
const payload = { ...formData };
// Clean up empty values
if (!payload.description || payload.description.trim() === '') delete payload.description;
if (!payload.min_rssi) delete payload.min_rssi;
if (!payload.max_rssi) delete payload.max_rssi;
if (!payload.device_ids || payload.device_ids.length === 0) delete payload.device_ids;
if (!payload.drone_types || payload.drone_types.length === 0) delete payload.drone_types;
// Only include webhook_url if webhook channel is selected
if (!payload.alert_channels || !payload.alert_channels.includes('webhook')) {
delete payload.webhook_url;
}
await api.post('/alerts/rules', payload);
onSave();
} catch (error) {
console.error('Error creating alert rule:', error);
} finally {
setSaving(false);
}
};
const handleChange = (e) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'number' ? parseInt(value) || 0 : value
}));
};
const handleChannelChange = (channel, checked) => {
setFormData(prev => {
// Ensure alert_channels is always an array
const currentChannels = Array.isArray(prev.alert_channels) ? prev.alert_channels : [];
return {
...prev,
alert_channels: checked
? [...currentChannels, channel]
: currentChannels.filter(c => c !== channel)
};
});
};
const handleDroneTypeChange = (droneType, checked) => {
setFormData(prev => ({
...prev,
drone_types: checked
? [...prev.drone_types, droneType]
: prev.drone_types.filter(type => type !== droneType)
}));
};
const handleDeviceChange = (deviceId, checked) => {
setFormData(prev => ({
...prev,
device_ids: checked
? [...prev.device_ids, deviceId]
: prev.device_ids.filter(id => id !== deviceId)
}));
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose} />
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
<form onSubmit={handleSubmit}>
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="mb-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Create Alert Rule
</h3>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Rule Name *
</label>
<input
type="text"
name="name"
required
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.name}
onChange={handleChange}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
name="description"
rows="2"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.description}
onChange={handleChange}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Priority
</label>
<select
name="priority"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.priority}
onChange={handleChange}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Min Detections
</label>
<input
type="number"
name="min_detections"
min="1"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.min_detections}
onChange={handleChange}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Time Window (seconds)
</label>
<input
type="number"
name="time_window"
min="60"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.time_window}
onChange={handleChange}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Cooldown Period (seconds)
</label>
<input
type="number"
name="cooldown_period"
min="0"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.cooldown_period}
onChange={handleChange}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Alert Channels
</label>
<div className="space-y-2">
{['sms', 'email', 'webhook'].map(channel => (
<label key={channel} className="flex items-center">
<input
type="checkbox"
checked={Array.isArray(formData.alert_channels) && formData.alert_channels.includes(channel)}
onChange={(e) => handleChannelChange(channel, e.target.checked)}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700 capitalize">
{channel}
</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Drone Types Filter
</label>
<div className="space-y-2">
<div className="text-xs text-gray-500 mb-2">
Leave empty to monitor all drone types
</div>
{droneTypes.map(droneType => (
<label key={droneType.id} className="flex items-center">
<input
type="checkbox"
checked={formData.drone_types.includes(droneType.id)}
onChange={(e) => handleDroneTypeChange(droneType.id, e.target.checked)}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">
{droneType.name}
<span className="text-xs text-gray-500 ml-1">({droneType.category})</span>
{droneType.threat_level === 'critical' && (
<span className="text-red-600 font-semibold ml-1">( High Threat)</span>
)}
</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Device Selection
</label>
<div className="space-y-2">
<div className="text-xs text-gray-500 mb-2">
Leave empty to monitor all approved devices
</div>
{loadingData ? (
<div className="text-sm text-gray-500">Loading devices...</div>
) : devices.length === 0 ? (
<div className="text-sm text-gray-500">No devices available</div>
) : (
devices.filter(device => device.is_approved).map(device => (
<label key={device.id} className="flex items-center">
<input
type="checkbox"
checked={formData.device_ids.includes(device.id)}
onChange={(e) => handleDeviceChange(device.id, e.target.checked)}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">
{device.name || `Device ${device.id}`}
<span className="text-xs text-gray-500 ml-1">
({device.location_description || 'No location'})
</span>
</span>
</label>
))
)}
</div>
</div>
{/* Conditional channel-specific fields */}
{Array.isArray(formData.alert_channels) && formData.alert_channels.includes('sms') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
SMS Phone Number
</label>
<input
type="tel"
name="sms_phone_number"
placeholder="+1234567890"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.sms_phone_number || ''}
onChange={handleChange}
/>
<div className="text-xs text-gray-500 mt-1">
Include country code (e.g., +1 for US)
</div>
</div>
)}
{Array.isArray(formData.alert_channels) && formData.alert_channels.includes('webhook') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Webhook URL *
</label>
<input
type="url"
name="webhook_url"
placeholder="https://your-webhook-endpoint.com/alerts"
required={Array.isArray(formData.alert_channels) && formData.alert_channels.includes('webhook')}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.webhook_url || ''}
onChange={handleChange}
/>
<div className="text-xs text-gray-500 mt-1">
Must be a valid HTTPS URL
</div>
</div>
)}
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="submit"
disabled={saving}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
>
{saving ? 'Creating...' : 'Create Rule'}
</button>
<button
type="button"
onClick={onClose}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
);
};
// Alert Details Modal Component
const AlertDetailsModal = ({ alert, parseErrorMessage, onClose }) => {
if (!alert) return null;
const errorInfo = parseErrorMessage(alert.error_message);
const getStatusBadge = (status) => {
const statusClasses = {
sent: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
pending: 'bg-yellow-100 text-yellow-800'
};
return (
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusClasses[status] || 'bg-gray-100 text-gray-800'}`}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
};
const renderSuccessDetails = (data) => (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<h4 className="text-lg font-medium text-green-800 mb-3"> Success Details</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
{data.recipient && (
<div>
<span className="font-medium text-green-700">Recipient:</span>
<div className="text-green-600">{data.recipient}</div>
</div>
)}
{data.messageLength && (
<div>
<span className="font-medium text-green-700">Message Length:</span>
<div className="text-green-600">{data.messageLength} characters</div>
</div>
)}
{data.timestamp && (
<div>
<span className="font-medium text-green-700">Processed At:</span>
<div className="text-green-600">{format(new Date(data.timestamp), 'MMM dd, yyyy HH:mm:ss')}</div>
</div>
)}
{data.simulatedDelivery !== undefined && (
<div>
<span className="font-medium text-green-700">Delivery Mode:</span>
<div className="text-green-600">{data.simulatedDelivery ? 'Simulated' : 'Real'}</div>
</div>
)}
{data.ruleId && (
<div className="col-span-2">
<span className="font-medium text-green-700">Rule ID:</span>
<div className="text-green-600 font-mono text-xs">{data.ruleId}</div>
</div>
)}
{data.detectionId && (
<div className="col-span-2">
<span className="font-medium text-green-700">Detection ID:</span>
<div className="text-green-600 font-mono text-xs">{data.detectionId}</div>
</div>
)}
</div>
</div>
);
const renderWebhookError = (data) => {
let webhookDetails = {};
// Parse the nested webhook error details
if (data.errorMessage && data.errorMessage.includes('Webhook HTTP')) {
try {
const match = data.errorMessage.match(/Webhook HTTP \d+: (.+)/);
if (match) {
webhookDetails = JSON.parse(match[1]);
}
} catch (e) {
console.error('Error parsing webhook details:', e);
}
}
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h4 className="text-lg font-medium text-red-800 mb-3"> Webhook Error Details</h4>
<div className="space-y-4">
{webhookDetails.httpStatus && (
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-red-700">HTTP Status:</span>
<div className="text-red-600 font-mono">{webhookDetails.httpStatus} {webhookDetails.httpStatusText}</div>
</div>
<div>
<span className="font-medium text-red-700">Response Time:</span>
<div className="text-red-600">{webhookDetails.responseTime}</div>
</div>
<div>
<span className="font-medium text-red-700">URL:</span>
<div className="text-red-600 break-all">{webhookDetails.url}</div>
</div>
<div>
<span className="font-medium text-red-700">Payload Size:</span>
<div className="text-red-600">{webhookDetails.requestPayload}</div>
</div>
{webhookDetails.timestamp && (
<div>
<span className="font-medium text-red-700">Failed At:</span>
<div className="text-red-600">{format(new Date(webhookDetails.timestamp), 'MMM dd, yyyy HH:mm:ss')}</div>
</div>
)}
</div>
)}
{webhookDetails.responseBody && (
<div>
<span className="font-medium text-red-700">Response Body:</span>
<div className="mt-1 p-2 bg-red-100 rounded text-xs text-red-600 font-mono max-h-32 overflow-y-auto">
{webhookDetails.responseBody}
</div>
</div>
)}
{webhookDetails.responseHeaders && (
<div>
<span className="font-medium text-red-700">Response Headers:</span>
<div className="mt-1 p-2 bg-red-100 rounded text-xs text-red-600 font-mono max-h-32 overflow-y-auto">
{JSON.stringify(webhookDetails.responseHeaders, null, 2)}
</div>
</div>
)}
</div>
</div>
);
};
const renderErrorDetails = (data, type) => {
if (type === 'webhook_error') {
return renderWebhookError(data);
}
// Generic error display for SMS and email errors
const errorTypeNames = {
sms_error: '📱 SMS Error Details',
email_error: '📧 Email Error Details'
};
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h4 className="text-lg font-medium text-red-800 mb-3">
{errorTypeNames[type] || '❌ Error Details'}
</h4>
<div className="text-sm">
<pre className="whitespace-pre-wrap text-red-600 font-mono text-xs max-h-64 overflow-y-auto">
{JSON.stringify(data, null, 2)}
</pre>
</div>
</div>
);
};
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-4/5 max-w-6xl shadow-lg rounded-md bg-white">
<div className="flex items-center justify-between pb-3 border-b">
<h3 className="text-xl font-medium">Alert Details</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<div className="space-y-6 mt-6">
{/* Basic Alert Information */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h4 className="text-lg font-medium text-gray-800 mb-3">📋 Basic Information</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-gray-700">Alert ID:</span>
<div className="text-gray-600 font-mono text-xs">{alert.id}</div>
</div>
<div>
<span className="font-medium text-gray-700">Status:</span>
<div className="mt-1">{getStatusBadge(alert.status)}</div>
</div>
<div>
<span className="font-medium text-gray-700">Type:</span>
<div className="text-gray-600 capitalize">{alert.alert_type}</div>
</div>
<div>
<span className="font-medium text-gray-700">Priority:</span>
<div className="text-gray-600 capitalize">{alert.priority}</div>
</div>
<div>
<span className="font-medium text-gray-700">Recipient:</span>
<div className="text-gray-600 break-all">{alert.recipient}</div>
</div>
<div>
<span className="font-medium text-gray-700">{t('alerts.retryCount')}:</span>
<div className="text-gray-600">{alert.retry_count}</div>
</div>
<div>
<span className="font-medium text-gray-700">Created At:</span>
<div className="text-gray-600">{format(new Date(alert.created_at), 'MMM dd, yyyy HH:mm:ss')}</div>
</div>
{alert.sent_at && (
<div>
<span className="font-medium text-gray-700">Sent At:</span>
<div className="text-gray-600">{format(new Date(alert.sent_at), 'MMM dd, yyyy HH:mm:ss')}</div>
</div>
)}
{alert.alert_event_id && (
<div className="col-span-2">
<span className="font-medium text-gray-700">Event ID:</span>
<div className="text-gray-600 font-mono text-xs">{alert.alert_event_id}</div>
</div>
)}
</div>
</div>
{/* Alert Message */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-lg font-medium text-blue-800 mb-3">💬 Alert Message</h4>
<div className="text-sm text-blue-700 whitespace-pre-wrap max-h-32 overflow-y-auto">
{alert.message}
</div>
</div>
{/* Error/Success Details */}
{errorInfo && (
<>
{errorInfo.type === 'success' && renderSuccessDetails(errorInfo.data)}
{(errorInfo.type === 'webhook_error' || errorInfo.type === 'sms_error' || errorInfo.type === 'email_error') &&
renderErrorDetails(errorInfo.data, errorInfo.type)}
{errorInfo.type === 'raw' && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h4 className="text-lg font-medium text-gray-800 mb-3">📝 Raw Error Message</h4>
<div className="text-sm text-gray-600 font-mono whitespace-pre-wrap max-h-32 overflow-y-auto">
{errorInfo.data.message}
</div>
</div>
)}
</>
)}
{/* Rule and Detection Info */}
{(alert.rule || alert.detection) && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<h4 className="text-lg font-medium text-purple-800 mb-3">🔗 Related Information</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
{alert.rule && (
<div>
<span className="font-medium text-purple-700">Alert Rule:</span>
<div className="text-purple-600">{alert.rule.name}</div>
<div className="text-purple-600 font-mono text-xs">ID: {alert.rule.id}</div>
</div>
)}
{alert.detection && (
<div>
<span className="font-medium text-purple-700">Detection:</span>
<div className="text-purple-600">Drone ID: {alert.detection.drone_id}</div>
<div className="text-purple-600">Type: {alert.detection.drone_type}</div>
<div className="text-purple-600">RSSI: {alert.detection.rssi} dBm</div>
</div>
)}
</div>
</div>
)}
</div>
<div className="flex justify-end mt-6 pt-4 border-t">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
};
export default Alerts;