Commit of Current Status
This commit is contained in:
65
components/FirmwareList.tsx
Normal file
65
components/FirmwareList.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { FirmwareUpdate } from '@/lib/api';
|
||||
import { Archive, Download, Clock } from 'lucide-react';
|
||||
|
||||
interface FirmwareListProps {
|
||||
updates: FirmwareUpdate[];
|
||||
}
|
||||
|
||||
export function FirmwareList({ updates }: FirmwareListProps) {
|
||||
return (
|
||||
<div className="bg-zinc-900/50 backdrop-blur-md border border-zinc-800 rounded-xl p-6 shadow-xl h-full">
|
||||
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Archive className="w-5 h-5 text-indigo-400" />
|
||||
Firmware Versions
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-zinc-400 text-sm">
|
||||
<th className="pb-3 pl-2">Version</th>
|
||||
<th className="pb-3">Description</th>
|
||||
<th className="pb-3">Uploaded</th>
|
||||
<th className="pb-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/50">
|
||||
{updates.map((fw) => (
|
||||
<tr key={fw.id} className="text-zinc-300 hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="py-3 pl-2">
|
||||
<span className="font-mono text-indigo-300 bg-indigo-500/10 px-2 py-1 rounded border border-indigo-500/20 text-xs">
|
||||
v{fw.version}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 text-sm text-zinc-400">{fw.description || '-'}</td>
|
||||
<td className="py-3 text-sm text-zinc-500">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(fw.uploadedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 text-right">
|
||||
<a
|
||||
href={`http://localhost:5000/api/vehicles/updates/${fw.id}/download`}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-1.5 text-xs bg-zinc-800 hover:bg-zinc-700 text-white px-2 py-1.5 rounded transition-colors"
|
||||
>
|
||||
<Download className="w-3 h-3" /> Bin
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{updates.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center py-8 text-zinc-500">
|
||||
No firmware available.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
components/GroupManager.tsx
Normal file
182
components/GroupManager.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { VehicleGroup, Vehicle, createGroup, updateGroup, assignVehicleToGroup } from '@/lib/api';
|
||||
import { Users, Plus, Edit2, X, Check } from 'lucide-react';
|
||||
|
||||
interface GroupManagerProps {
|
||||
groups: VehicleGroup[];
|
||||
vehicles: Vehicle[];
|
||||
onGroupChanged: () => void;
|
||||
}
|
||||
|
||||
export function GroupManager({ groups, vehicles, onGroupChanged }: GroupManagerProps) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Vehicle Assignment State
|
||||
const [assignVin, setAssignVin] = useState('');
|
||||
|
||||
const startCreate = () => {
|
||||
setIsCreating(true);
|
||||
setEditingId(null);
|
||||
setName('');
|
||||
setDescription('');
|
||||
setAssignVin('');
|
||||
};
|
||||
|
||||
const startEdit = (group: VehicleGroup) => {
|
||||
setIsCreating(false);
|
||||
setEditingId(group.id);
|
||||
setName(group.name);
|
||||
setDescription(group.description || '');
|
||||
setAssignVin('');
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setIsCreating(false);
|
||||
setEditingId(null);
|
||||
setName('');
|
||||
setDescription('');
|
||||
setAssignVin('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isCreating) {
|
||||
await createGroup(name, description);
|
||||
} else if (editingId) {
|
||||
await updateGroup(editingId, name, description);
|
||||
}
|
||||
onGroupChanged();
|
||||
cancel();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Operation failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignVehicle = async () => {
|
||||
if (!editingId || !assignVin) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await assignVehicleToGroup(editingId, assignVin);
|
||||
onGroupChanged();
|
||||
alert('Vehicle assigned');
|
||||
setAssignVin('');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to assign vehicle');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900/50 backdrop-blur-md border border-zinc-800 rounded-xl p-6 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-orange-400" />
|
||||
Vehicle Groups
|
||||
</h2>
|
||||
<button
|
||||
onClick={startCreate}
|
||||
className="text-xs bg-zinc-800 hover:bg-zinc-700 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> New Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(isCreating || editingId) && (
|
||||
<div className="mb-4 bg-zinc-950/50 p-4 rounded-lg border border-zinc-800/50">
|
||||
<form onSubmit={handleSubmit} className="space-y-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="Group Name"
|
||||
className="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-white text-sm outline-none focus:ring-1 focus:ring-orange-500"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
className="w-full bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-white text-sm outline-none focus:ring-1 focus:ring-orange-500"
|
||||
/>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 bg-orange-500 hover:bg-orange-600 text-white text-xs font-medium py-1.5 rounded transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
<Check className="w-3 h-3" /> Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
className="flex-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-medium py-1.5 rounded transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
<X className="w-3 h-3" /> Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{editingId && (
|
||||
<div className="border-t border-zinc-800 pt-3">
|
||||
<h4 className="text-xs font-medium text-zinc-400 mb-2">Assign Vehicle</h4>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={assignVin}
|
||||
onChange={e => setAssignVin(e.target.value)}
|
||||
className="flex-1 bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-xs text-white outline-none"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{vehicles.map(v => (
|
||||
<option key={v.vin} value={v.vin}>{v.vin} {v.groupId === editingId ? '(Already in group)' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleAssignVehicle}
|
||||
disabled={loading || !assignVin}
|
||||
className="bg-zinc-800 hover:bg-zinc-700 text-white px-2 py-1 rounded text-xs"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto pr-1">
|
||||
{groups.map(group => (
|
||||
<div key={group.id} className="group flex items-center justify-between p-3 bg-zinc-900/30 border border-zinc-800/30 rounded-lg hover:border-zinc-700 transition-colors">
|
||||
<div>
|
||||
<h3 className="text-zinc-200 font-medium text-sm">{group.name}</h3>
|
||||
<p className="text-zinc-500 text-xs">{group.vehicles?.length || 0} vehicles</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => startEdit(group)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1.5 text-zinc-500 hover:text-white hover:bg-zinc-800 rounded transition-all"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{groups.length === 0 && <p className="text-center text-zinc-500 text-sm py-4">No groups created.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
components/UpdateManager.tsx
Normal file
199
components/UpdateManager.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { FirmwareUpdate, Vehicle, VehicleGroup, uploadUpdate, createDeployment } from '@/lib/api';
|
||||
import { Upload, Rocket, HardDrive, CheckCircle2, AlertCircle, Users } from 'lucide-react';
|
||||
|
||||
interface UpdateManagerProps {
|
||||
updates: FirmwareUpdate[];
|
||||
vehicles: Vehicle[];
|
||||
groups: VehicleGroup[];
|
||||
onUpdateDeployed: () => void;
|
||||
}
|
||||
|
||||
export function UpdateManager({ updates, vehicles, groups, onUpdateDeployed }: UpdateManagerProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [version, setVersion] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const [selectedUpdate, setSelectedUpdate] = useState<number | null>(null);
|
||||
const [targetType, setTargetType] = useState<'vehicle' | 'group'>('vehicle');
|
||||
const [selectedTarget, setSelectedTarget] = useState<string>(''); // VIN or Group ID
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
|
||||
const handleUpload = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!file || !version) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('version', version);
|
||||
formData.append('description', description);
|
||||
await uploadUpdate(formData);
|
||||
onUpdateDeployed();
|
||||
setFile(null);
|
||||
setVersion('');
|
||||
setDescription('');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!selectedUpdate || !selectedTarget) return;
|
||||
|
||||
setDeploying(true);
|
||||
try {
|
||||
if (targetType === 'vehicle') {
|
||||
await createDeployment(selectedUpdate, selectedTarget, undefined);
|
||||
} else {
|
||||
await createDeployment(selectedUpdate, undefined, parseInt(selectedTarget));
|
||||
}
|
||||
onUpdateDeployed();
|
||||
alert('Deployment started!');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Deployment failed');
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Upload Section */}
|
||||
<div className="bg-zinc-900/50 backdrop-blur-md border border-zinc-800 rounded-xl p-6 shadow-xl">
|
||||
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Upload className="w-5 h-5 text-blue-400" />
|
||||
Upload Firmware
|
||||
</h2>
|
||||
<form onSubmit={handleUpload} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Version</label>
|
||||
<input
|
||||
type="text"
|
||||
value={version}
|
||||
onChange={e => setVersion(e.target.value)}
|
||||
placeholder="e.g. 1.2.0"
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Critical security patch..."
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Binary File</label>
|
||||
<div className="relative border-2 border-dashed border-zinc-800 rounded-lg p-4 hover:border-zinc-700 transition-colors text-center cursor-pointer group">
|
||||
<input
|
||||
type="file"
|
||||
onChange={e => setFile(e.target.files?.[0] || null)}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
required
|
||||
/>
|
||||
<HardDrive className="w-6 h-6 text-zinc-600 mx-auto mb-2 group-hover:text-zinc-400 transition-colors" />
|
||||
<p className="text-sm text-zinc-500">
|
||||
{file ? file.name : "Click to select binary"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 text-white font-medium py-2 rounded-lg transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload Firmware'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Deploy Section */}
|
||||
<div className="bg-zinc-900/50 backdrop-blur-md border border-zinc-800 rounded-xl p-6 shadow-xl">
|
||||
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Rocket className="w-5 h-5 text-purple-400" />
|
||||
Deploy Update
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Select Firmware</label>
|
||||
<select
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-white outline-none"
|
||||
onChange={e => setSelectedUpdate(Number(e.target.value))}
|
||||
value={selectedUpdate || ''}
|
||||
>
|
||||
<option value="">-- Select Version --</option>
|
||||
{updates.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.version} - {u.description || 'No desc'}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||
<button
|
||||
onClick={() => setTargetType('vehicle')}
|
||||
className={`py-2 text-sm rounded-lg border transition-all ${targetType === 'vehicle' ? 'bg-zinc-800 border-zinc-700 text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
|
||||
>
|
||||
Single Vehicle
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTargetType('group')}
|
||||
className={`py-2 text-sm rounded-lg border transition-all ${targetType === 'group' ? 'bg-zinc-800 border-zinc-700 text-white' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
|
||||
>
|
||||
Entire Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Target {targetType === 'vehicle' ? 'Vehicle' : 'Group'}</label>
|
||||
{targetType === 'vehicle' ? (
|
||||
<select
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-white outline-none"
|
||||
onChange={e => setSelectedTarget(e.target.value)}
|
||||
value={selectedTarget}
|
||||
>
|
||||
<option value="">-- Select Vehicle --</option>
|
||||
{vehicles.map(v => (
|
||||
<option key={v.vin} value={v.vin}>{v.vin} (v{v.currentVersion})</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-white outline-none"
|
||||
onChange={e => setSelectedTarget(e.target.value)}
|
||||
value={selectedTarget}
|
||||
>
|
||||
<option value="">-- Select Group --</option>
|
||||
{groups.map(g => (
|
||||
<option key={g.id} value={g.id}>{g.name} ({g.vehicles?.length || 0} vehicles)</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDeploy}
|
||||
disabled={deploying || !selectedUpdate || !selectedTarget}
|
||||
className="w-full bg-purple-600 hover:bg-purple-500 text-white font-medium py-2 rounded-lg transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{deploying ? 'Deploying...' : 'Deploy Update'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recent Deployments Snippet could go here */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
components/VehicleList.tsx
Normal file
78
components/VehicleList.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { Vehicle } from '@/lib/api';
|
||||
import { Car, Clock, Signal, Layers } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface VehicleListProps {
|
||||
vehicles: Vehicle[];
|
||||
}
|
||||
|
||||
export function VehicleList({ vehicles }: VehicleListProps) {
|
||||
return (
|
||||
<div className="bg-zinc-900/50 backdrop-blur-md border border-zinc-800 rounded-xl p-6 shadow-xl">
|
||||
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Car className="w-5 h-5 text-indigo-400" />
|
||||
Fleet Overview
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-zinc-400 text-sm">
|
||||
<th className="pb-3 pl-2">VIN</th>
|
||||
<th className="pb-3">Group</th>
|
||||
<th className="pb-3">Status</th>
|
||||
<th className="pb-3">Version</th>
|
||||
<th className="pb-3">Last Heartbeat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/50">
|
||||
{vehicles.map((vehicle) => {
|
||||
// Calculate status: Offline if no heartbeat for 5s
|
||||
const displayStatus = getVehicleDisplayStatus(vehicle);
|
||||
|
||||
return (
|
||||
<tr key={vehicle.vin} className="text-zinc-300 hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="py-3 pl-2 font-mono text-sm">{vehicle.vin}</td>
|
||||
<td className="py-3">
|
||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-zinc-800 text-zinc-400">
|
||||
<Layers className="w-3 h-3" />
|
||||
{vehicle.group?.name || 'Unassigned'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<span className={clsx(
|
||||
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium",
|
||||
displayStatus === 'Online' && "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20",
|
||||
displayStatus === 'Offline' && "bg-red-500/10 text-red-400 border border-red-500/20",
|
||||
displayStatus === 'Updating' && "bg-amber-500/10 text-amber-400 border border-amber-500/20 animate-pulse"
|
||||
)}>
|
||||
<Signal className="w-3 h-3" />
|
||||
{displayStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<span className="font-mono text-zinc-400 bg-zinc-900 px-2 py-1 rounded border border-zinc-800">
|
||||
v{vehicle.currentVersion}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 text-sm text-zinc-500 flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(vehicle.lastHeartbeat).toLocaleTimeString()}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{vehicles.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-zinc-500">
|
||||
No vehicles registered yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user