Commit of Current Status

This commit is contained in:
Denis Urs Rudolph
2025-12-11 20:58:10 +01:00
parent d492352d50
commit 6de23e0e77
10 changed files with 827 additions and 58 deletions

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}