diff --git a/app/page.tsx b/app/page.tsx index 295f8fd..488fb1e 100644 --- a/app/page.tsx +++ b/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() { + const [vehicles, setVehicles] = useState([]); + const [updates, setUpdates] = useState([]); + const [groups, setGroups] = useState([]); + 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 ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

+
+
+ {/* Header */} +
+
+

+ OTA Fleet Commander +

+

Manage firmware updates for your vehicle fleet

+
+
+
+ + + + + System Active +
+
+
+ + {/* Stats */} +
+
+
+ +
+
+

Total Vehicles

+

{vehicles.length}

+
+
+
+
+ +
+
+

Online Status

+

{onlineCount} / {vehicles.length}

+
+
+
+
+ +
+
+

Current Version

+ {/* Naive logic: most common version or just listing */} +

+ {updates[0]?.version || 'v1.0.0'} +

+
+
- -
-
+
+ ); } diff --git a/bun.lock b/bun.lock index 5862717..cc284fb 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/components/FirmwareList.tsx b/components/FirmwareList.tsx new file mode 100644 index 0000000..68964ad --- /dev/null +++ b/components/FirmwareList.tsx @@ -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 ( +
+

+ + Firmware Versions +

+
+ + + + + + + + + + + {updates.map((fw) => ( + + + + + + + ))} + {updates.length === 0 && ( + + + + )} + +
VersionDescriptionUploadedAction
+ + v{fw.version} + + {fw.description || '-'} +
+ + {new Date(fw.uploadedAt).toLocaleDateString()} +
+
+ + Bin + +
+ No firmware available. +
+
+
+ ); +} diff --git a/components/GroupManager.tsx b/components/GroupManager.tsx new file mode 100644 index 0000000..b52eaac --- /dev/null +++ b/components/GroupManager.tsx @@ -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(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 ( +
+
+

+ + Vehicle Groups +

+ +
+ + {(isCreating || editingId) && ( +
+
+ 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 + /> + 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" + /> +
+ + +
+
+ + {editingId && ( +
+

Assign Vehicle

+
+ + +
+
+ )} +
+ )} + +
+ {groups.map(group => ( +
+
+

{group.name}

+

{group.vehicles?.length || 0} vehicles

+
+ +
+ ))} + {groups.length === 0 &&

No groups created.

} +
+
+ ); +} diff --git a/components/UpdateManager.tsx b/components/UpdateManager.tsx new file mode 100644 index 0000000..ee0cd53 --- /dev/null +++ b/components/UpdateManager.tsx @@ -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(null); + const [version, setVersion] = useState(''); + const [description, setDescription] = useState(''); + const [uploading, setUploading] = useState(false); + + const [selectedUpdate, setSelectedUpdate] = useState(null); + const [targetType, setTargetType] = useState<'vehicle' | 'group'>('vehicle'); + const [selectedTarget, setSelectedTarget] = useState(''); // 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 ( +
+ {/* Upload Section */} +
+

+ + Upload Firmware +

+
+
+ + 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 + /> +
+
+ + 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" + /> +
+
+ +
+ setFile(e.target.files?.[0] || null)} + className="absolute inset-0 opacity-0 cursor-pointer" + required + /> + +

+ {file ? file.name : "Click to select binary"} +

+
+
+ +
+
+ + {/* Deploy Section */} +
+

+ + Deploy Update +

+
+
+ + +
+ +
+ + +
+ +
+ + {targetType === 'vehicle' ? ( + + ) : ( + + )} +
+ + +
+ + {/* Recent Deployments Snippet could go here */} +
+
+ ); +} diff --git a/components/VehicleList.tsx b/components/VehicleList.tsx new file mode 100644 index 0000000..475ee53 --- /dev/null +++ b/components/VehicleList.tsx @@ -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 ( +
+

+ + Fleet Overview +

+
+ + + + + + + + + + + + {vehicles.map((vehicle) => { + // Calculate status: Offline if no heartbeat for 5s + const displayStatus = getVehicleDisplayStatus(vehicle); + + return ( + + + + + + + + ) + })} + {vehicles.length === 0 && ( + + + + )} + +
VINGroupStatusVersionLast Heartbeat
{vehicle.vin} + + + {vehicle.group?.name || 'Unassigned'} + + + + + {displayStatus} + + + + v{vehicle.currentVersion} + + + + {new Date(vehicle.lastHeartbeat).toLocaleTimeString()} +
+ No vehicles registered yet. +
+
+
+ ); +} diff --git a/lib/api.ts b/lib/api.ts new file mode 100644 index 0000000..6fa5950 --- /dev/null +++ b/lib/api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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'); +} diff --git a/lib/utils.test.ts b/lib/utils.test.ts new file mode 100644 index 0000000..878046c --- /dev/null +++ b/lib/utils.test.ts @@ -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"); + }); +}); diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..7ed140b --- /dev/null +++ b/lib/utils.ts @@ -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; +} diff --git a/package.json b/package.json index 716e632..377e144 100644 --- a/package.json +++ b/package.json @@ -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",