1492 lines
62 KiB
JavaScript
1492 lines
62 KiB
JavaScript
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 {
|
||
console.log('🔄 Fetching alerts data...');
|
||
|
||
const [rulesRes, logsRes, statsRes] = await Promise.all([
|
||
api.get('/alerts/rules'),
|
||
api.get('/alerts/logs?limit=50'),
|
||
api.get('/alerts/stats?hours=24')
|
||
]);
|
||
|
||
console.log('📊 API Responses:', {
|
||
rules: rulesRes.data,
|
||
logs: logsRes.data,
|
||
stats: statsRes.data
|
||
});
|
||
|
||
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))
|
||
});
|
||
|
||
// Check for any objects containing the problematic keys
|
||
alertRules?.forEach((rule, index) => {
|
||
if (rule.alert_channels && typeof rule.alert_channels === 'object') {
|
||
const keys = Object.keys(rule.alert_channels);
|
||
const hasProblematicKeys = ['sms', 'webhook', 'email'].some(key => keys.includes(key));
|
||
if (hasProblematicKeys) {
|
||
console.warn(`🚨 Rule ${index} has object alert_channels:`, 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;
|