Initial commit
This commit is contained in:
437
client/src/pages/Devices.jsx
Normal file
437
client/src/pages/Devices.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user