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

@@ -1,65 +1,114 @@
import Image from "next/image";
'use client';
import { useEffect, useState } from 'react';
import { fetchVehicles, getUpdates, fetchGroups, Vehicle, FirmwareUpdate, VehicleGroup } from '@/lib/api';
import { VehicleList } from '@/components/VehicleList';
import { UpdateManager } from '@/components/UpdateManager';
import { FirmwareList } from '@/components/FirmwareList';
import { GroupManager } from '@/components/GroupManager';
import { Activity, ShieldCheck, Truck } from 'lucide-react';
export default function Home() {
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [updates, setUpdates] = useState<FirmwareUpdate[]>([]);
const [groups, setGroups] = useState<VehicleGroup[]>([]);
const [loading, setLoading] = useState(true);
const loadData = async () => {
try {
const [v, u, g] = await Promise.all([fetchVehicles(), getUpdates(), fetchGroups()]);
setVehicles(v);
setUpdates(u);
setGroups(g);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
const interval = setInterval(loadData, 3000); // Poll every 3s
return () => clearInterval(interval);
}, []);
const onlineCount = vehicles.filter(v => v.status === 'Online').length;
const updatingCount = vehicles.filter(v => v.status === 'Updating').length;
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
<main className="min-h-screen bg-black text-zinc-100 selection:bg-indigo-500/30">
<div className="max-w-7xl mx-auto px-6 py-10 space-y-8">
{/* Header */}
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">
OTA Fleet Commander
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
<p className="text-zinc-500 mt-1">Manage firmware updates for your vehicle fleet</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 bg-zinc-900 px-4 py-2 rounded-lg border border-zinc-800">
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-emerald-500"></span>
</span>
<span className="text-sm font-medium text-emerald-400">System Active</span>
</div>
</div>
</header>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-zinc-900/50 border border-zinc-800 p-4 rounded-xl flex items-center gap-4">
<div className="p-3 rounded-lg bg-indigo-500/10 text-indigo-400">
<Truck className="w-6 h-6" />
</div>
<div>
<p className="text-zinc-500 text-sm">Total Vehicles</p>
<p className="text-2xl font-bold">{vehicles.length}</p>
</div>
</div>
<div className="bg-zinc-900/50 border border-zinc-800 p-4 rounded-xl flex items-center gap-4">
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<Activity className="w-6 h-6" />
</div>
<div>
<p className="text-zinc-500 text-sm">Online Status</p>
<p className="text-2xl font-bold">{onlineCount} <span className="text-sm text-zinc-500 font-normal">/ {vehicles.length}</span></p>
</div>
</div>
<div className="bg-zinc-900/50 border border-zinc-800 p-4 rounded-xl flex items-center gap-4">
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
<ShieldCheck className="w-6 h-6" />
</div>
<div>
<p className="text-zinc-500 text-sm">Current Version</p>
{/* Naive logic: most common version or just listing */}
<p className="text-lg font-bold truncate max-w-[150px]">
{updates[0]?.version || 'v1.0.0'}
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
</div>
</div>
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-8">
<VehicleList vehicles={vehicles} />
<UpdateManager
updates={updates}
vehicles={vehicles}
groups={groups}
onUpdateDeployed={loadData}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
<div className="space-y-8">
<GroupManager groups={groups} vehicles={vehicles} onGroupChanged={loadData} />
<FirmwareList updates={updates} />
</div>
</div>
</div>
</main>
</div>
);
}

View File

@@ -5,9 +5,12 @@
"": {
"name": "ota-dashboard",
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^0.560.0",
"next": "16.0.8",
"react": "19.2.1",
"react-dom": "19.2.1",
"tailwind-merge": "^3.4.0",
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -346,6 +349,8 @@
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -632,6 +637,8 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.560.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-NwKoUA/aBShsdL8WE5lukV2F/tjHzQRlonQs7fkNGI1sCT0Ay4a9Ap3ST2clUUkcY+9eQ0pBe2hybTQd2fmyDA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -782,6 +789,8 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],

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

104
lib/api.ts Normal file
View File

@@ -0,0 +1,104 @@
const API_URL = 'http://localhost:5000/api';
export interface Vehicle {
vin: string;
status: string;
currentVersion: string;
lastHeartbeat: string;
groupId?: number;
group?: VehicleGroup;
}
export interface VehicleGroup {
id: number;
name: string;
description?: string;
vehicles?: Vehicle[];
}
export interface FirmwareUpdate {
id: number;
version: string;
description?: string;
uploadedAt: string;
}
export interface Deployment {
id: number;
updateId: number;
targetVin?: string;
targetGroupId?: number;
status: string;
createdAt: string;
update: FirmwareUpdate;
}
export async function fetchVehicles(): Promise<Vehicle[]> {
const res = await fetch(`${API_URL}/admin/vehicles`);
if (!res.ok) throw new Error('Failed to fetch vehicles');
return res.json();
}
export async function fetchGroups(): Promise<VehicleGroup[]> {
const res = await fetch(`${API_URL}/admin/groups`);
if (!res.ok) throw new Error('Failed to fetch groups');
return res.json();
}
export async function getUpdates(): Promise<FirmwareUpdate[]> {
const res = await fetch(`${API_URL}/admin/updates`);
if (!res.ok) throw new Error('Failed to fetch updates');
return res.json();
}
export async function createGroup(name: string, description?: string): Promise<VehicleGroup> {
const res = await fetch(`${API_URL}/admin/groups`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description }),
});
if (!res.ok) throw new Error('Failed to create group');
return res.json();
}
export async function updateGroup(id: number, name: string, description?: string): Promise<VehicleGroup> {
const res = await fetch(`${API_URL}/admin/groups/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description }),
});
if (!res.ok) throw new Error('Failed to update group');
return res.json();
}
export async function assignVehicleToGroup(groupId: number, vin: string): Promise<void> {
const res = await fetch(`${API_URL}/admin/groups/${groupId}/vehicles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vin }),
});
if (!res.ok) throw new Error('Failed to assign vehicle to group');
}
export async function fetchDeployments(): Promise<Deployment[]> {
const res = await fetch(`${API_URL}/admin/deployments`);
if (!res.ok) throw new Error('Failed to fetch deployments');
return res.json();
}
export async function uploadUpdate(formData: FormData): Promise<void> {
const res = await fetch(`${API_URL}/admin/updates`, {
method: 'POST',
body: formData,
});
if (!res.ok) throw new Error('Failed to upload update');
}
export async function createDeployment(updateId: number, targetVin?: string, targetGroupId?: number): Promise<void> {
const res = await fetch(`${API_URL}/admin/deployments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updateId, targetVin, targetGroupId }),
});
if (!res.ok) throw new Error('Failed to create deployment');
}

62
lib/utils.test.ts Normal file
View File

@@ -0,0 +1,62 @@
import { describe, it, expect, setSystemTime, beforeAll, afterAll } from "bun:test";
import { getVehicleDisplayStatus } from "./utils";
import { Vehicle } from "./api";
describe("getVehicleDisplayStatus", () => {
beforeAll(() => {
setSystemTime(new Date("2024-01-01T12:00:10Z")); // Mock Current Time: 12:00:10 UTC
});
afterAll(() => {
setSystemTime(); // Reset
});
it("should return Online if heartbeat is recent (<5s)", () => {
const vehicle: Vehicle = {
vin: "TEST",
status: "Online",
currentVersion: "1.0",
lastHeartbeat: "2024-01-01T12:00:08", // 2 seconds ago (assuming UTC fix)
groupId: null
};
// The function appends Z -> 12:00:08Z. Diff is 2000ms.
expect(getVehicleDisplayStatus(vehicle)).toBe("Online");
});
it("should return Offline if heartbeat is old (>5s)", () => {
const vehicle: Vehicle = {
vin: "TEST",
status: "Online",
currentVersion: "1.0",
lastHeartbeat: "2024-01-01T12:00:04", // 6 seconds ago
groupId: null
};
// The function appends Z -> 12:00:04Z. Diff is 6000ms.
expect(getVehicleDisplayStatus(vehicle)).toBe("Offline");
});
it("should handle already UTC string with Z", () => {
const vehicle: Vehicle = {
vin: "TEST",
status: "Online",
currentVersion: "1.0",
lastHeartbeat: "2024-01-01T12:00:09Z", // 1s ago
groupId: null
};
expect(getVehicleDisplayStatus(vehicle)).toBe("Online");
});
it("should preserve Updating status even if old heartbeat", () => {
// Usually updating vehicles might not heartbeat? Or they do.
// Logic says: if (displayStatus === 'Online' && diffMs > 5000) -> Offline.
// If status is 'Updating', it stays 'Updating'.
const vehicle: Vehicle = {
vin: "TEST",
status: "Updating",
currentVersion: "1.0",
lastHeartbeat: "2024-01-01T12:00:00", // 10s ago
groupId: null
};
expect(getVehicleDisplayStatus(vehicle)).toBe("Updating");
});
});

18
lib/utils.ts Normal file
View File

@@ -0,0 +1,18 @@
import { Vehicle } from "./api";
export function getVehicleDisplayStatus(vehicle: Vehicle): 'Online' | 'Offline' | 'Updating' | string {
// Calculate status: Offline if no heartbeat for 5s
let lastHeartbeatStr = vehicle.lastHeartbeat;
// Fix timezone if missing Z
if (!lastHeartbeatStr.endsWith('Z')) {
lastHeartbeatStr += 'Z';
}
const lastHeartbeatDate = new Date(lastHeartbeatStr);
const diffMs = Date.now() - lastHeartbeatDate.getTime();
let displayStatus = vehicle.status;
if (displayStatus === 'Online' && diffMs > 5000) {
displayStatus = 'Offline';
}
return displayStatus;
}

View File

@@ -9,9 +9,12 @@
"lint": "eslint"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^0.560.0",
"next": "16.0.8",
"react": "19.2.1",
"react-dom": "19.2.1"
"react-dom": "19.2.1",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",