Commit of Current Status
This commit is contained in:
157
app/page.tsx
157
app/page.tsx
@@ -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() {
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<main className="min-h-screen bg-black text-zinc-100 selection:bg-indigo-500/30">
|
||||||
<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">
|
<div className="max-w-7xl mx-auto px-6 py-10 space-y-8">
|
||||||
<Image
|
{/* Header */}
|
||||||
className="dark:invert"
|
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
src="/next.svg"
|
<div>
|
||||||
alt="Next.js logo"
|
<h1 className="text-3xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">
|
||||||
width={100}
|
OTA Fleet Commander
|
||||||
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.
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
<p className="text-zinc-500 mt-1">Manage firmware updates for your vehicle fleet</p>
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
</div>
|
||||||
<a
|
<div className="flex items-center gap-4">
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<div className="flex items-center gap-2 bg-zinc-900 px-4 py-2 rounded-lg border border-zinc-800">
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
<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>
|
||||||
Templates
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-emerald-500"></span>
|
||||||
</a>{" "}
|
</span>
|
||||||
or the{" "}
|
<span className="text-sm font-medium text-emerald-400">System Active</span>
|
||||||
<a
|
</div>
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</div>
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
</header>
|
||||||
>
|
|
||||||
Learning
|
{/* Stats */}
|
||||||
</a>{" "}
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
center.
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
</div>
|
||||||
<a
|
</div>
|
||||||
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"
|
{/* Main Content */}
|
||||||
target="_blank"
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
rel="noopener noreferrer"
|
<div className="lg:col-span-2 space-y-8">
|
||||||
>
|
<VehicleList vehicles={vehicles} />
|
||||||
<Image
|
<UpdateManager
|
||||||
className="dark:invert"
|
updates={updates}
|
||||||
src="/vercel.svg"
|
vehicles={vehicles}
|
||||||
alt="Vercel logomark"
|
groups={groups}
|
||||||
width={16}
|
onUpdateDeployed={loadData}
|
||||||
height={16}
|
|
||||||
/>
|
/>
|
||||||
Deploy Now
|
</div>
|
||||||
</a>
|
<div className="space-y-8">
|
||||||
<a
|
<GroupManager groups={groups} vehicles={vehicles} onGroupChanged={loadData} />
|
||||||
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]"
|
<FirmwareList updates={updates} />
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</div>
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
9
bun.lock
9
bun.lock
@@ -5,9 +5,12 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "ota-dashboard",
|
"name": "ota-dashboard",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.560.0",
|
||||||
"next": "16.0.8",
|
"next": "16.0.8",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -346,6 +349,8 @@
|
|||||||
|
|
||||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
lib/api.ts
Normal file
104
lib/api.ts
Normal 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
62
lib/utils.test.ts
Normal 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
18
lib/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -9,9 +9,12 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.560.0",
|
||||||
"next": "16.0.8",
|
"next": "16.0.8",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1"
|
"react-dom": "19.2.1",
|
||||||
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
Reference in New Issue
Block a user