Files
drone-detector/client/src/pages/Alerts.jsx
2025-09-22 10:15:56 +02:00

1067 lines
46 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 { t } from '../utils/tempTranslations';
import {
PlusIcon,
BellIcon,
CheckCircleIcon,
XCircleIcon,
ExclamationTriangleIcon,
ChevronDownIcon,
ChevronRightIcon
} from '@heroicons/react/24/outline';
import { EditAlertModal, DetectionDetailsModal } from '../components/AlertModals';
const Alerts = () => {
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 [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);
}
};
// 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
const getDroneTypeInfo = (detection) => {
if (!detection || detection.drone_type === undefined) {
return { name: 'Unknown', color: 'gray', bgColor: 'bg-gray-100', textColor: 'text-gray-600', icon: '🔍' };
}
// Map drone types based on the backend droneTypes utility
const droneTypeMap = {
0: { name: 'Unknown', color: 'gray', bgColor: 'bg-gray-100', textColor: 'text-gray-600', icon: '🔍' },
1: { name: 'Generic', color: 'blue', bgColor: 'bg-blue-100', textColor: 'text-blue-800', icon: '🚁' },
2: { name: 'Orlan', color: 'red', bgColor: 'bg-red-100', textColor: 'text-red-800', icon: '⚠️' },
3: { name: 'Zala', color: 'orange', bgColor: 'bg-orange-100', textColor: 'text-orange-800', icon: '🔶' },
4: { name: 'Forpost', color: 'purple', bgColor: 'bg-purple-100', textColor: 'text-purple-800', icon: '🟣' },
5: { name: 'Inokhodets', color: 'indigo', bgColor: 'bg-indigo-100', textColor: 'text-indigo-800', icon: '🔷' },
6: { name: 'Lancet', color: 'red', bgColor: 'bg-red-200', textColor: 'text-red-900', icon: '💥' },
7: { name: 'Shahed', color: 'yellow', bgColor: 'bg-yellow-100', textColor: 'text-yellow-800', icon: '⚡' },
8: { name: 'Geran', color: 'amber', bgColor: 'bg-amber-100', textColor: 'text-amber-800', icon: '🟨' },
9: { name: 'Kub', color: 'green', bgColor: 'bg-green-100', textColor: 'text-green-800', icon: '🟢' },
10: { name: 'X-UAV', color: 'teal', bgColor: 'bg-teal-100', textColor: 'text-teal-800', icon: '🔷' },
11: { name: 'SuperCam', color: 'cyan', bgColor: 'bg-cyan-100', textColor: 'text-cyan-800', icon: '📷' },
12: { name: 'Eleron', color: 'lime', bgColor: 'bg-lime-100', textColor: 'text-lime-800', icon: '🟩' },
13: { name: 'DJI', color: 'blue', bgColor: 'bg-blue-200', textColor: 'text-blue-900', icon: '📱' },
14: { name: 'Autel', color: 'violet', bgColor: 'bg-violet-100', textColor: 'text-violet-800', icon: '🟪' },
15: { name: 'Parrot', color: 'emerald', bgColor: 'bg-emerald-100', textColor: 'text-emerald-800', icon: '🦜' },
16: { name: 'Skydio', color: 'sky', bgColor: 'bg-sky-100', textColor: 'text-sky-800', icon: '☁️' },
17: { name: 'CryptoOrlan', color: 'red', bgColor: 'bg-red-300', textColor: 'text-red-900', icon: '🔴' }
};
return droneTypeMap[detection.drone_type] || droneTypeMap[0];
};
const handleDeleteRule = async (ruleId) => {
if (window.confirm('Are you sure you want to delete this alert rule?')) {
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);
}
};
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 (loading) {
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">{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)
}`}>
{t(`alerts.${rule.priority}`)}
</span>
</td>
<td>
<div className="flex space-x-1">
{(rule.alert_channels || []).map((channel, index) => (
<span
key={index}
className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs"
>
{channel}
</span>
))}
</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) => {
const droneTypeKeys = {
0: 'consumer',
1: 'orlan',
2: 'professional',
3: 'racing',
4: 'unknown'
};
return (
<span
key={index}
className={`px-1.5 py-0.5 rounded text-xs font-medium ${
typeId === 1
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{t(`alerts.${droneTypeKeys[typeId] || 'unknown'}`)}
{typeId === 1 && '⚠️'}
</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>{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.name === 'Orlan' && (
<span className="text-red-600 font-bold text-xs"> MILITARY</span>
)}
{droneTypeInfo.name === 'Lancet' && (
<span className="text-red-600 font-bold text-xs">💥 KAMIKAZE</span>
)}
{droneTypeInfo.name === 'CryptoOrlan' && (
<span className="text-red-600 font-bold text-xs">🔴 ENCRYPTED</span>
)}
{droneTypeInfo.name === 'DJI' && (
<span className="text-blue-600 font-medium text-xs">📱 COMMERCIAL</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>
<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>
</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>
<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);
}}
/>
)}
</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 => ({
...prev,
alert_channels: checked
? [...prev.alert_channels, channel]
: prev.alert_channels.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={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 */}
{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>
)}
{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={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>
);
};
export default Alerts;