Initial commit

This commit is contained in:
2025-08-16 19:43:44 +02:00
commit ea9a2627b4
64 changed files with 9232 additions and 0 deletions

View File

@@ -0,0 +1,437 @@
import React, { useState, useEffect } from 'react';
import api from '../services/api';
import { format } from 'date-fns';
import {
PlusIcon,
PencilIcon,
TrashIcon,
ServerIcon,
MapPinIcon,
SignalIcon,
BoltIcon
} from '@heroicons/react/24/outline';
const Devices = () => {
const [devices, setDevices] = useState([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [editingDevice, setEditingDevice] = useState(null);
useEffect(() => {
fetchDevices();
}, []);
const fetchDevices = async () => {
try {
const response = await api.get('/devices?include_stats=true');
setDevices(response.data.data);
} catch (error) {
console.error('Error fetching devices:', error);
} finally {
setLoading(false);
}
};
const handleAddDevice = () => {
setEditingDevice(null);
setShowAddModal(true);
};
const handleEditDevice = (device) => {
setEditingDevice(device);
setShowAddModal(true);
};
const handleDeleteDevice = async (deviceId) => {
if (window.confirm('Are you sure you want to deactivate this device?')) {
try {
await api.delete(`/devices/${deviceId}`);
fetchDevices();
} catch (error) {
console.error('Error deleting device:', error);
}
}
};
const getStatusColor = (status) => {
switch (status) {
case 'online':
return 'bg-green-100 text-green-800';
case 'offline':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getSignalStrength = (lastHeartbeat) => {
if (!lastHeartbeat) return 'Unknown';
const timeSince = (new Date() - new Date(lastHeartbeat)) / 1000 / 60; // minutes
if (timeSince < 5) return 'Strong';
if (timeSince < 15) return 'Good';
if (timeSince < 60) return 'Weak';
return 'Lost';
};
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>
</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">
Devices
</h3>
<p className="mt-1 text-sm text-gray-500">
Manage your drone detection devices
</p>
</div>
<button
onClick={handleAddDevice}
className="btn btn-primary flex items-center space-x-2"
>
<PlusIcon className="h-4 w-4" />
<span>Add Device</span>
</button>
</div>
{/* Device Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{devices.map((device) => (
<div key={device.id} className="bg-white rounded-lg shadow border border-gray-200 hover:shadow-md transition-shadow">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className={`w-3 h-3 rounded-full ${
device.stats?.status === 'online' ? 'bg-green-400' : 'bg-red-400'
}`} />
<h4 className="text-lg font-medium text-gray-900">
{device.name || `Device ${device.id}`}
</h4>
</div>
<div className="flex space-x-1">
<button
onClick={() => handleEditDevice(device)}
className="p-1 text-gray-400 hover:text-gray-600"
>
<PencilIcon className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteDevice(device.id)}
className="p-1 text-gray-400 hover:text-red-600"
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Status</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
getStatusColor(device.stats?.status)
}`}>
{device.stats?.status || 'Unknown'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Device ID</span>
<span className="text-sm font-medium text-gray-900">
{device.id}
</span>
</div>
{device.location_description && (
<div className="flex items-start justify-between">
<span className="text-sm text-gray-500">Location</span>
<span className="text-sm text-gray-900 text-right">
{device.location_description}
</span>
</div>
)}
{(device.geo_lat && device.geo_lon) && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Coordinates</span>
<span className="text-sm text-gray-900">
{device.geo_lat}, {device.geo_lon}
</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Signal</span>
<span className="text-sm text-gray-900">
{getSignalStrength(device.last_heartbeat)}
</span>
</div>
{device.stats && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Detections (24h)</span>
<span className={`text-sm font-medium ${
device.stats.detections_24h > 0 ? 'text-red-600' : 'text-green-600'
}`}>
{device.stats.detections_24h}
</span>
</div>
)}
{device.last_heartbeat && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Last Seen</span>
<span className="text-sm text-gray-900">
{format(new Date(device.last_heartbeat), 'MMM dd, HH:mm')}
</span>
</div>
)}
{device.firmware_version && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Firmware</span>
<span className="text-sm text-gray-900">
{device.firmware_version}
</span>
</div>
)}
</div>
{/* Device Actions */}
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="flex space-x-2">
<button className="flex-1 text-xs bg-gray-100 text-gray-700 py-2 px-3 rounded hover:bg-gray-200 transition-colors">
View Details
</button>
<button className="flex-1 text-xs bg-primary-100 text-primary-700 py-2 px-3 rounded hover:bg-primary-200 transition-colors">
View on Map
</button>
</div>
</div>
</div>
</div>
))}
</div>
{devices.length === 0 && (
<div className="text-center py-12">
<ServerIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No devices</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by adding your first drone detection device.
</p>
<div className="mt-6">
<button
onClick={handleAddDevice}
className="btn btn-primary"
>
<PlusIcon className="h-4 w-4 mr-2" />
Add Device
</button>
</div>
</div>
)}
{/* Add/Edit Device Modal */}
{showAddModal && (
<DeviceModal
device={editingDevice}
onClose={() => setShowAddModal(false)}
onSave={() => {
setShowAddModal(false);
fetchDevices();
}}
/>
)}
</div>
);
};
const DeviceModal = ({ device, onClose, onSave }) => {
const [formData, setFormData] = useState({
id: device?.id || '',
name: device?.name || '',
geo_lat: device?.geo_lat || '',
geo_lon: device?.geo_lon || '',
location_description: device?.location_description || '',
heartbeat_interval: device?.heartbeat_interval || 300,
firmware_version: device?.firmware_version || '',
notes: device?.notes || ''
});
const [saving, setSaving] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
try {
if (device) {
// Update existing device
await api.put(`/devices/${device.id}`, formData);
} else {
// Create new device
await api.post('/devices', formData);
}
onSave();
} catch (error) {
console.error('Error saving device:', error);
} finally {
setSaving(false);
}
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
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-lg 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">
{device ? 'Edit Device' : 'Add New Device'}
</h3>
</div>
<div className="space-y-4">
{!device && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Device ID *
</label>
<input
type="number"
name="id"
required
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.id}
onChange={handleChange}
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Device Name
</label>
<input
type="text"
name="name"
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 className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Latitude
</label>
<input
type="number"
step="any"
name="geo_lat"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.geo_lat}
onChange={handleChange}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Longitude
</label>
<input
type="number"
step="any"
name="geo_lon"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.geo_lon}
onChange={handleChange}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Location Description
</label>
<input
type="text"
name="location_description"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.location_description}
onChange={handleChange}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Heartbeat Interval (seconds)
</label>
<input
type="number"
name="heartbeat_interval"
min="60"
max="3600"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.heartbeat_interval}
onChange={handleChange}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notes
</label>
<textarea
name="notes"
rows="3"
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.notes}
onChange={handleChange}
/>
</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 ? 'Saving...' : (device ? 'Update' : 'Create')}
</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 Devices;