334 lines
13 KiB
JavaScript
334 lines
13 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import api from '../services/api';
|
|
import { format } from 'date-fns';
|
|
import {
|
|
MagnifyingGlassIcon,
|
|
FunnelIcon,
|
|
EyeIcon
|
|
} from '@heroicons/react/24/outline';
|
|
|
|
const Detections = () => {
|
|
const [detections, setDetections] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [pagination, setPagination] = useState({});
|
|
const [filters, setFilters] = useState({
|
|
device_id: '',
|
|
drone_id: '',
|
|
start_date: '',
|
|
end_date: '',
|
|
limit: 50,
|
|
offset: 0
|
|
});
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchDetections();
|
|
}, [filters]);
|
|
|
|
const fetchDetections = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const params = new URLSearchParams();
|
|
|
|
Object.entries(filters).forEach(([key, value]) => {
|
|
if (value) params.append(key, value);
|
|
});
|
|
|
|
console.log('🔍 Fetching detections with params:', params.toString());
|
|
const response = await api.get(`/detections?${params}`);
|
|
console.log('✅ Detections response:', response.data);
|
|
|
|
setDetections(response.data.data?.detections || []);
|
|
setPagination(response.data.data?.pagination || {});
|
|
} catch (error) {
|
|
console.error('❌ Error fetching detections:', error);
|
|
setDetections([]); // Ensure detections is always an array
|
|
setPagination({});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleFilterChange = (key, value) => {
|
|
setFilters(prev => ({
|
|
...prev,
|
|
[key]: value,
|
|
offset: 0 // Reset to first page when filtering
|
|
}));
|
|
};
|
|
|
|
const handlePageChange = (newOffset) => {
|
|
setFilters(prev => ({
|
|
...prev,
|
|
offset: newOffset
|
|
}));
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setFilters({
|
|
device_id: '',
|
|
drone_id: '',
|
|
start_date: '',
|
|
end_date: '',
|
|
limit: 50,
|
|
offset: 0
|
|
});
|
|
};
|
|
|
|
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">
|
|
Drone Detections
|
|
</h3>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
History of all drone detections from your devices
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowFilters(!showFilters)}
|
|
className="btn btn-secondary flex items-center space-x-2"
|
|
>
|
|
<FunnelIcon className="h-4 w-4" />
|
|
<span>Filters</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
{showFilters && (
|
|
<div className="bg-white p-6 rounded-lg shadow border">
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Device ID
|
|
</label>
|
|
<input
|
|
type="number"
|
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
|
placeholder="Device ID"
|
|
value={filters.device_id}
|
|
onChange={(e) => handleFilterChange('device_id', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Drone ID
|
|
</label>
|
|
<input
|
|
type="number"
|
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
|
placeholder="Drone ID"
|
|
value={filters.drone_id}
|
|
onChange={(e) => handleFilterChange('drone_id', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Start Date
|
|
</label>
|
|
<input
|
|
type="datetime-local"
|
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
|
value={filters.start_date}
|
|
onChange={(e) => handleFilterChange('start_date', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
End Date
|
|
</label>
|
|
<input
|
|
type="datetime-local"
|
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
|
value={filters.end_date}
|
|
onChange={(e) => handleFilterChange('end_date', e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 flex space-x-2">
|
|
<button
|
|
onClick={clearFilters}
|
|
className="btn btn-secondary"
|
|
>
|
|
Clear Filters
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Detection List */}
|
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="table-responsive">
|
|
<table className="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Device</th>
|
|
<th>Drone ID</th>
|
|
<th>Type</th>
|
|
<th>Frequency</th>
|
|
<th>RSSI</th>
|
|
<th>Location</th>
|
|
<th>Detected At</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{detections.map((detection) => (
|
|
<tr key={detection.id} className="hover:bg-gray-50">
|
|
<td>
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900">
|
|
{detection.device?.name || `Device ${detection.device_id}`}
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
ID: {detection.device_id}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<span className="px-2 py-1 bg-red-100 text-red-800 rounded-full text-sm font-medium">
|
|
{detection.drone_id}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<span className="text-sm text-gray-900">
|
|
{detection.drone_type_info?.name || `Type ${detection.drone_type}`}
|
|
</span>
|
|
{detection.drone_type_info?.category && (
|
|
<div className="text-xs text-gray-500">
|
|
{detection.drone_type_info.category}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td>
|
|
<span className="text-sm text-gray-900">
|
|
{detection.freq} MHz
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<span className={`text-sm font-medium ${
|
|
detection.rssi > -60 ? 'text-red-600' :
|
|
detection.rssi > -80 ? 'text-yellow-600' : 'text-green-600'
|
|
}`}>
|
|
{detection.rssi} dBm
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<div className="text-sm text-gray-900">
|
|
{detection.device?.location_description ||
|
|
(detection.geo_lat && detection.geo_lon ?
|
|
`${detection.geo_lat}, ${detection.geo_lon}` :
|
|
'Unknown')}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div className="text-sm text-gray-900">
|
|
{format(new Date(detection.server_timestamp), 'MMM dd, yyyy')}
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
{format(new Date(detection.server_timestamp), 'HH:mm:ss')}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<button
|
|
className="text-primary-600 hover:text-primary-900 text-sm"
|
|
onClick={() => {
|
|
// TODO: Open detection details modal
|
|
console.log('View detection details:', detection);
|
|
}}
|
|
>
|
|
<EyeIcon className="h-4 w-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{detections.length === 0 && !loading && (
|
|
<div className="text-center py-12">
|
|
<MagnifyingGlassIcon className="mx-auto h-12 w-12 text-gray-400" />
|
|
<h3 className="mt-2 text-sm font-medium text-gray-900">No detections found</h3>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
Try adjusting your search filters.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{pagination.total > 0 && (
|
|
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
|
<div className="flex-1 flex justify-between sm:hidden">
|
|
<button
|
|
onClick={() => handlePageChange(Math.max(0, filters.offset - filters.limit))}
|
|
disabled={filters.offset === 0}
|
|
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
Previous
|
|
</button>
|
|
<button
|
|
onClick={() => handlePageChange(filters.offset + filters.limit)}
|
|
disabled={filters.offset + filters.limit >= pagination.total}
|
|
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
|
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-700">
|
|
Showing{' '}
|
|
<span className="font-medium">{filters.offset + 1}</span>
|
|
{' '}to{' '}
|
|
<span className="font-medium">
|
|
{Math.min(filters.offset + filters.limit, pagination.total)}
|
|
</span>
|
|
{' '}of{' '}
|
|
<span className="font-medium">{pagination.total}</span>
|
|
{' '}results
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
|
<button
|
|
onClick={() => handlePageChange(Math.max(0, filters.offset - filters.limit))}
|
|
disabled={filters.offset === 0}
|
|
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
Previous
|
|
</button>
|
|
<button
|
|
onClick={() => handlePageChange(filters.offset + filters.limit)}
|
|
disabled={filters.offset + filters.limit >= pagination.total}
|
|
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
Next
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Detections;
|