Merge branch 'Bradley'

This commit is contained in:
2025-09-10 09:09:23 +01:00
37 changed files with 5347 additions and 760 deletions

18
TODO.txt Normal file
View File

@@ -0,0 +1,18 @@
TODO:
Hotlist upload (Question for Dion about API) and hits popping up in sighting stack.
NPED API working and catagories popping up in sighting stack. Images added to public folder.
Make the friendly name of each camera permeate throughout.
Make favicon MAV logo.
Swipe down to get to session page.
I have made an error I don't know how to fix in SightingFeedProvider.tsx
There is a bug in /front-camera-settings where the navigation arrow doesn't have a transparent background. I don't know why it is only that one and I can't find out why. Very strange.
The selected sighting in the sighting stack seems a tad buggy. Sometimes multiple get selected.
Can the selected sighting be shown in full detail. How this will look is still up for debate. Either as a pop up card as in AiQ Flexi, or in the OVerview card??
How do you know if the time has sync? Make UTC red if not sync.
Can the relative aspect ratio in SightingOverview.tsx be the ratio of image pixel size of the image to best take advantage of the space?
FYI:
Session, WiFi and Modem stuff isn't implimented in the backend. Those are just placeholders for now.

4308
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/DVLA_Cat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
public/NPED.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

BIN
public/NPED_Cat_A.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
public/NPED_Cat_B.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
public/NPED_Cat_C.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
public/homepage-banner.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -4,6 +4,7 @@ import { Route, Routes } from "react-router";
import FrontCamera from "./pages/FrontCamera";
import RearCamera from "./pages/RearCamera";
import SystemSettings from "./pages/SystemSettings";
import Session from "./pages/Session";
import { NPEDUserProvider } from "./context/providers/NPEDUserContextProvider";
function App() {
@@ -15,6 +16,7 @@ function App() {
<Route path="front-camera-settings" element={<FrontCamera />} />
<Route path="rear-camera-settings" element={<RearCamera />} />
<Route path="system-settings" element={<SystemSettings />} />
<Route path="session-settings" element={<Session />} />
</Route>
</Routes>
</NPEDUserProvider>

View File

@@ -71,6 +71,7 @@ const CameraSettingFields = () => {
type="text"
className="p-2 border border-gray-400 rounded-lg"
placeholder="RTSP://..."
autoComplete="street-address"
/>
</div>

View File

@@ -26,7 +26,7 @@ const FrontCameraOverviewCard = ({ className }: CardProps) => {
)}
>
<div className="flex flex-col space-y-3 h-full" {...handlers}>
<CardHeader title="Front Overiew" icon={faCamera} />
<CardHeader title="Front Overview" icon={faCamera} />
<SightingOverview />
{/* <SnapshotContainer side="TargetDetectionFront" /> */}
</div>

View File

@@ -25,7 +25,7 @@ const RearCameraOverviewCard = ({ className }: CardProps) => {
)}
>
<div className="flex flex-col space-y-3 h-full" {...handlers}>
<CardHeader title="Rear Overiew" icon={faCamera} />
<CardHeader title="Rear Overview" icon={faCamera} />
<SightingOverview />
{/* <SnapshotContainer side="TargetDetectionRear" /> */}
</div>

View File

@@ -0,0 +1,35 @@
import Card from "../UI/Card";
import CardHeader from "../UI/CardHeader";
import FormGroup from "../SettingForms/components/FormGroup";
const SessionCard = () => {
return (
<Card>
<CardHeader title={"Hit Search"} />
<div className="flex flex-col gap-4">
<FormGroup>
<label htmlFor="VRM" className="font-medium whitespace-nowrap md:w-1/2 text-left">VRM (Min 2 letters)</label>
<div className="flex-1 flex justify-end md:w-2/3">
<input
id="VRMSelect"
name="VRMSelect"
type="text"
className="p-2 border border-gray-400 rounded-lg w-full max-w-xs"
placeholder="Enter VRM"
//onChange={e => setSntpServer(e.target.value)}
/>
</div>
</FormGroup>
<button
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full max-w-md"
//onClick={() => handleModemSave(apn, username, password, authType)}
>
Search Hit list
</button>
</div>
</Card>
);
};
export default SessionCard;

View File

@@ -0,0 +1,27 @@
import Card from "../UI/Card";
import CardHeader from "../UI/CardHeader";
const SessionCard = () => {
return (
<Card>
<CardHeader title={"Session"} />
<div className="flex flex-col gap-4">
<button
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full max-w-md"
//onClick={() => handleModemSave(apn, username, password, authType)}
>
Start Session
</button>
<h2 className="text-white mb-2">Number of cars: </h2>
<h2 className="text-white mb-2">Cars without Tax: </h2>
<h2 className="text-white mb-2">Cars without MOT: </h2>
<h2 className="text-white mb-2">Cars with NPED Cat A: </h2>
<h2 className="text-white mb-2">Cars with NPED Cat B: </h2>
<h2 className="text-white mb-2">Cars with NPED Cat C: </h2>
</div>
</Card>
);
};
export default SessionCard;

View File

@@ -5,7 +5,7 @@ import NPEDFields from "./NPEDFields";
const NPEDCard = () => {
return (
<Card>
<CardHeader title={"NPED Config"} />
<CardHeader title={"NPED Config"} img={"/NPED.jpg"} />
<NPEDFields />
</Card>
);

View File

@@ -0,0 +1,15 @@
export async function handleSoftReboot() {
const response = await fetch(
`http://192.168.75.11/api/restart-flexiai`
);
if (!response.ok) throw new Error("Failed to Software Reboot");
else alert("Software reboot triggered!");
}
export async function handleHardReboot() {
const response = await fetch(
`http://192.168.75.11/api/restart-hardware`
);
if (!response.ok) throw new Error("Failed to Hardware Reboot");
else alert("Hardware reboot triggered!");
}

View File

@@ -0,0 +1,74 @@
export async function handleSystemSave(deviceName: string, sntpServer: string, sntpInterval: number, timeZone: string) {
const payload = { // Build JSON
id: "GLOBAL--Device",
fields: [
{ property: "propDeviceName", value: deviceName },
{ property: "propSNTPServer", value: sntpServer },
{ property: "propSNTPIntervalMinutes", value: Number(sntpInterval) },
{ property: "propLocalTimeZone", value: timeZone }
]
};
try {
const response = await fetch("http://192.168.75.11/api/update-config", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`HTTP ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`);
}
alert("System Settings Saved Successfully!");
} catch (err) {
console.error(err);
}
}
export async function handleSystemRecall() {
const url = "http://192.168.75.11/api/fetch-config?id=GLOBAL--Device";
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 7000);
try {
const response = await fetch(url, {
method: "GET",
headers: { "Accept": "application/json" },
signal: controller.signal
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`HTTP ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`);
}
const data = await response.json();
const deviceName = data?.propDeviceName?.value ?? null;
const sntpServer = data?.propSNTPServer?.value ?? null;
const timeZone = data?.propLocalTimeZone?.value ?? null;
let sntpIntervalRaw = data?.propSNTPIntervalMinutes?.value;
let sntpInterval =
typeof sntpIntervalRaw === "number"
? sntpIntervalRaw
: Number.parseInt(String(sntpIntervalRaw).trim(), 10);
if (!Number.isFinite(sntpInterval)) {
sntpInterval = 60;
}
return { deviceName, sntpServer, sntpInterval, timeZone };
} catch (err) {
console.error(err);
return null;
} finally {
clearTimeout(timeoutId);
}
}

View File

@@ -0,0 +1,219 @@
import React from "react";
import { useEffect } from "react"
import Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader";
import FormGroup from "../components/FormGroup";
import { sendBlobFileUpload } from "./Upload";
import { handleSoftReboot, handleHardReboot } from "./Reboots.tsx";
import { handleSystemRecall, handleSystemSave } from "./SettingSaveRecall.tsx";
const SystemCard = () => {
const [deviceName, setDeviceName] = React.useState("");
const [timeZone, setTimeZone] = React.useState("Europe/London (UTC+00:00");
const [sntpServer, setSntpServer] = React.useState("1.uk.pool.ntp.org");
const [sntpInterval, setSntpInterval] = React.useState(60);
const [selectedFile, setSelectedFile] = React.useState<File | null>(null);
const [error, setError] = React.useState("");
useEffect(() => {
(async () => {
const result = await handleSystemRecall(); // returns { deviceName, sntpServer, sntpInterval, timeZone } | null
if (result) {
const {
deviceName: dn,
sntpServer: ss,
sntpInterval: si,
timeZone: tz
} = result;
setDeviceName(dn ?? "");
setSntpServer(ss ?? "");
setSntpInterval(Number.isFinite(si) ? si : 60);
setTimeZone(tz ?? "UTC (UTC-00)");
}
})();
}, []);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
setSelectedFile(file);
if (!file) {
setError("No file selected.");
return;
}
if (file.size > 8 * 1024 * 1024) {
setError("File is too large (max 8MB).");
setSelectedFile(null);
return
};
setError("");
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); // prevent full page reload
if (!selectedFile) {
setError("Please select a file before uploading.");
return;
}
setError("");
const result = await sendBlobFileUpload( selectedFile, {
timeoutMs: 30000,
fieldName: "upload",
});
// The helper returns a string (either success body or formatted error)
// You can decide how to distinguish. Here, we show it optimistically and let the text speak.
if (result.startsWith("Server returned") || result.startsWith("Timeout") || result.startsWith("HTTP error") || result.startsWith("Unexpected error")) {
setError(result);
}
};
return (
<Card className="flex flex-col items-center justify-center">
<CardHeader title={"System Config"} />
<div className="flex flex-col gap-4 w-full items-left max-w-md">
<FormGroup>
<label htmlFor="deviceName" className="font-medium whitespace-nowrap md:w-1/2 text-left">Device Name</label>
<div className="flex-1 flex justify-end md:w-2/3">
<input
id="deviceName"
name="deviceName"
type="text"
className="p-2 border border-gray-400 rounded-lg w-full max-w-xs"
placeholder="Enter device name"
value={deviceName}
onChange={e => setDeviceName(e.target.value)}
/>
</div>
</FormGroup>
<FormGroup>
<label htmlFor="timeZone" className="font-medium whitespace-nowrap md:w-1/2 text-left">Local Time Zone</label>
<div className="flex-1 flex justify-end md:w-2/3">
<select
id="timeZone"
name="timeZone"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] w-full max-w-xs"
value={timeZone}
onChange={e => setTimeZone(e.target.value)}
>
<option value="">Select Time Zone</option>
<option value="Europe/London (UTC+00)">UTC (UTC+00)</option>
<option value="Africa/Cairo (UTC+02)">Africa/Cairo (UTC+02)</option>
<option value="Africa/Johannesburg (UTC+02)">Africa/Johannesburg (UTC+02)</option>
<option value="Africa/Lagos (UTC+01)">Africa/Lagos (UTC+01)</option>
<option value="Africa/Monrousing (UTC+00)">Africa/Monrousing (UTC+00)</option>
<option value="America/Anchorage (UTC-09)">America/Anchorage (UTC-09)</option>
<option value="America/Chicago (UTC-06)">America/Chicago (UTC-06)</option>
<option value="America/Denver (UTC-07)">America/Denver (UTC-07)</option>
<option value="America/Edmonton (UTC-07)">America/Edmonton (UTC-07)</option>
<option value="America/Jamaica (UTC-05)">America/Jamaica (UTC-05)</option>
<option value="America/Los Angeles (UTC-08)">America/Los Angeles (UTC-08)</option>
<option value="America/Mexico City (UTC-06)">America/Mexico City (UTC-06)</option>
<option value="America/Montreal (UTC-05)">America/Montreal (UTC-05)</option>
<option value="America/New York (UTC-05)">America/New York (UTC-05)</option>
<option value="America/Phoenix (UTC-07)">America/Phoenix (UTC-07)</option>
<option value="America/Puerto Rico (UTC-04)">America/Puerto Rico (UTC-04)</option>
<option value="America/Sao Paulo (UTC-03)">America/Sao Paulo (UTC-03)</option>
<option value="America/Toronto (UTC-05)">America/Toronto (UTC-05)</option>
<option value="America/Vancouver (UTC-08)">America/Vancouver (UTC-08)</option>
<option value="Asia/Hong Kong (UTC+08)">Asia/Hong Kong (UTC+08)</option>
<option value="Asia/Jerusalem (UTC+02)">Asia/Jerusalem (UTC+02)</option>
<option value="Asia/Manila (UTC+08)">Asia/Manila (UTC+08)</option>
<option value="Asia/Seoul (UTC+09)">Asia/Seoul (UTC+09)</option>
<option value="Asia/Tokyo (UTC+09)">Asia/Tokyo (UTC+09)</option>
<option value="Atlantic/Reykjavik (UTC+00)">Atlantic/Reykjavik (UTC+00)</option>
<option value="Australia/Perth (UTC+08)">Australia/Perth (UTC+08)</option>
<option value="Australia/Sydney (UTC+10)">Australia/Sydney (UTC+10)</option>
<option value="Europe/Athens (UTC+02)">Europe/Athens (UTC+02)</option>
<option value="Europe/Berlin (UTC+01)">Europe/Berlin (UTC+01)</option>
<option value="Europe/Brussels (UTC+01)">Europe/Brussels (UTC+01)</option>
<option value="Europe/Copenhagen (UTC+01)">Europe/Copenhagen (UTC+01)</option>
<option value="Europe/London (UTC+00)">Europe/London (UTC+00)</option>
<option value="Europe/Madrid (UTC+01)">Europe/Madrid (UTC+01)</option>
<option value="Europe/Moscow (UTC+04)">Europe/Moscow (UTC+04)</option>
<option value="Europe/Paris (UTC+01)">Europe/Paris (UTC+01)</option>
<option value="Europe/Prague (UTC+01)">Europe/Prague (UTC+01)</option>
<option value="Europe/Rome (UTC+01)">Europe/Rome (UTC+01)</option>
<option value="Europe/Warsaw (UTC+01)">Europe/Warsaw (UTC+01)</option>
<option value="Pacific/Guam (UTC+10)">Pacific/Guam (UTC+10)</option>
<option value="Pacific/Honolulu (UTC-10)">Pacific/Honolulu (UTC-10)</option>
</select>
</div>
</FormGroup>
<FormGroup>
<label htmlFor="sntpServer" className="font-medium whitespace-nowrap md:w-1/2 text-left">SNTP Server</label>
<div className="flex-1 flex justify-end md:w-2/3">
<input
id="sntpServer"
name="sntpServer"
type="text"
className="p-2 border border-gray-400 rounded-lg w-full max-w-xs"
placeholder="Enter SNTP server address"
value={sntpServer}
onChange={e => setSntpServer(e.target.value)}
/>
</div>
</FormGroup>
<FormGroup>
<label htmlFor="sntpInterval" className="font-medium whitespace-nowrap md:w-1/2 text-left">SNTP Interval minutes</label>
<div className="flex-1 flex justify-end md:w-2/3">
<input
id="sntpInterval"
name="sntpInterval"
type="number"
min={1}
className="p-2 border border-gray-400 rounded-lg w-full max-w-xs"
value={sntpInterval}
onChange={e => setSntpInterval(Number(e.target.value))}
/>
</div>
</FormGroup>
<button
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full max-w-md"
onClick={() => handleSystemSave(deviceName, sntpServer, sntpInterval, timeZone)}
>
Save System Settings
</button>
<div className="py-8 w-full">
<form onSubmit={handleSubmit} className="flex flex-col items-center gap-2 w-full">
<FormGroup>
<div className="flex-1 flex justify-end md:w-2/3">
<input
type="file"
name="softwareUpdate"
id="softwareUpdate"
className="file:px-10 file:border file:border-gray-500 file:rounded-lg file:bg-blue-800 file:mr-5 w-full max-w-xs"
onChange={handleFileChange}
/>
</div>
</FormGroup>
<button
type="submit"
className="w-full max-w-md text-white bg-[#26B170] hover:bg-green-700 font-small rounded-lg text-sm px-2 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!selectedFile}
>
Upload Software Update
</button>
{error && <p className="text-red-500 text-sm">{error}</p>}
</form>
</div>
<button
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition w-full max-w-md"
onClick={handleSoftReboot}
>
Software Reboot
</button>
<button
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition w-full max-w-md"
onClick={handleHardReboot}
>
Hardware Reboot
</button>
</div>
</Card>
);
};
export default SystemCard;

View File

@@ -0,0 +1,42 @@
// CORS (server missing Access-Control-Allow-* headers)??
export async function sendBlobFileUpload(
file: File,
opts?: { timeoutMs?: number; fieldName?: string; overrideFileName?: string }
): Promise<string> {
const timeoutMs = opts?.timeoutMs ?? 30000;
const fieldName = opts?.fieldName ?? "upload";
const fileName = opts?.overrideFileName ?? file.name;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const form = new FormData();
form.append(fieldName, file, fileName);
const resp = await fetch('http://192.168.75.11/upload/software-update/2', {
method: "POST",
body: form,
signal: controller.signal,
redirect: "follow",
});
const bodyText = await resp.text();
if (!resp.ok) {
return `Server returned ${resp.status}: ${resp.statusText}. Details: ${bodyText}`;
}
return bodyText;
} catch (err: any) {
if (err?.name === "AbortError") {
return `Timeout uploading to /upload/software-update/2.`;
}
// In browsers, fetch throws TypeError on network-level failures
if (err instanceof TypeError) {
return `HTTP error uploading to /upload/software-update/2: ${err.message}`;
}
return `Unexpected error uploading to /upload/software-update/2: ${err?.message ?? String(err)} ${err?.cause ?? ""}`;
} finally {
clearTimeout(timeout);
}
}

View File

@@ -0,0 +1,77 @@
import Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader";
import { useState } from "react";
import FormGroup from "../components/FormGroup";
const ModemCard = () => {
const [apn, setApn] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [authType, setAuthType] = useState("PAP");
return (
<Card>
<CardHeader title={"Modem"} />
<div className="flex flex-col gap-4">
<FormGroup>
<label htmlFor="apn" className="font-medium whitespace-nowrap md:w-2/3">APN</label>
<input
id="apn"
name="apn"
type="text"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter APN"
value={apn}
onChange={e => setApn(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label htmlFor="modemUsername" className="font-medium whitespace-nowrap md:w-2/3">Username</label>
<input
id="modemUsername"
name="modemUsername"
type="text"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter Username"
value={username}
onChange={e => setUsername(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label htmlFor="modemPassword" className="font-medium whitespace-nowrap md:w-2/3">Password</label>
<input
id="modemPassword"
name="modemPassword"
type="password"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter Password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label htmlFor="authType" className="font-medium whitespace-nowrap md:w-2/3">Authentication Type</label>
<select
id="authType"
name="authType"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] flex-1 md:w-2/3"
value={authType}
onChange={e => setAuthType(e.target.value)}
>
<option value="PAP">PAP</option>
<option value="CHAP">CHAP</option>
<option value="None">None</option>
</select>
</FormGroup>
<button
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full max-w-md"
//onClick={() => handleModemSave(apn, username, password, authType)}
>
Save Modem Settings
</button>
</div>
</Card>
);
};
export default ModemCard;

View File

@@ -0,0 +1,65 @@
import Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader";
import { useState } from "react";
import FormGroup from "../components/FormGroup";
const WiFiCard = () => {
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [encryption, setEncryption] = useState("WPA2");
return (
<Card className="mb-4">
<CardHeader title={"WiFi"} />
<div className="flex flex-col gap-4">
<FormGroup>
<label htmlFor="ssid" className="font-medium whitespace-nowrap md:w-2/3">SSID</label>
<input
id="ssid"
name="ssid"
type="text"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter SSID"
value={ssid}
onChange={e => setSsid(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label htmlFor="password" className="font-medium whitespace-nowrap md:w-2/3">Password</label>
<input
id="password"
name="password"
type="password"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter Password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label htmlFor="encryption" className="font-medium whitespace-nowrap md:w-2/3">WPA/Encryption Type</label>
<select
id="encryption"
name="encryption"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] flex-1 md:w-2/3"
value={encryption}
onChange={e => setEncryption(e.target.value)}
>
<option value="WPA2">WPA2</option>
<option value="WPA3">WPA3</option>
<option value="WEP">WEP</option>
<option value="None">None</option>
</select>
</FormGroup>
<button
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full max-w-md"
//onClick={() => handleWiFiSave(ssid, password, encryption)}
>
Save WiFi Settings
</button>
</div>
</Card>
);
};
export default WiFiCard;

View File

@@ -35,7 +35,7 @@ const SightingOverview = () => {
<div className="mt-2 grid gap-3">
<div className="inline-block w-[90%] mx-auto" {...handlers}>
<NavigationArrow side={side} />
<div className="relative aspect-[5/4]">
<div className="relative aspect-[1280/800]">
<img
ref={imgRef}
onLoad={() => {

View File

@@ -9,6 +9,14 @@ const SightingWidgetDetails = ({
}: SightingWidgetDetailsProps) => {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
<div>
VRM:{" "}
<span className="opacity-90">{effectiveSelected?.vrm ?? "—"}</span>
</div>
<div>
Timestamp:{" "}
<span className="opacity-90">{effectiveSelected?.timeStamp ?? "—"}</span>
</div>
<div>
Make:{" "}
<span className="opacity-90">{effectiveSelected?.make ?? "—"}</span>
@@ -17,6 +25,16 @@ const SightingWidgetDetails = ({
Model:{" "}
<span className="opacity-90">{effectiveSelected?.model ?? "—"}</span>
</div>
<div>
Country:{" "}
<span className="opacity-90">{effectiveSelected?.countryCode ?? "—"}</span>
</div>
<div>
Seen:{" "}
<span className="opacity-90">
{effectiveSelected?.seenCount ?? "—"}
</span>
</div>
<div>
Colour:{" "}
<span className="opacity-90">{effectiveSelected?.color ?? "—"}</span>
@@ -43,40 +61,8 @@ const SightingWidgetDetails = ({
{effectiveSelected?.overviewSize ?? "—"}
</span>
</div>
<div>
Motion:{" "}
<span className="opacity-90">{effectiveSelected?.motion ?? "—"}</span>
</div>
<div>
Seen:{" "}
<span className="opacity-90">
{effectiveSelected?.seenCount ?? "—"}
</span>
</div>
<div>
Location:{" "}
<span className="opacity-90">
{effectiveSelected?.locationName ?? "—"}
</span>
</div>
<div>
Lane:{" "}
<span className="opacity-90">{effectiveSelected?.laneID ?? "—"}</span>
</div>
<div>
Radar:{" "}
<span className="opacity-90">
{effectiveSelected?.radarSpeed ?? "—"}
</span>
</div>
<div>
Track:{" "}
<span className="opacity-90">
{effectiveSelected?.trackSpeed ?? "—"}
</span>
</div>
{effectiveSelected?.detailsUrl ? (
<div className="col-span-full">
<div className="col-span-half">
<a
href={effectiveSelected.detailsUrl}
target="_blank"

View File

@@ -10,7 +10,7 @@ const Card = ({ children, className }: CardProps) => {
return (
<div
className={clsx(
"bg-[#253445] rounded-lg mt-6 mx-4 px-4 py-4 shadow-2xl overflow-y-auto md:row-span-1",
"bg-[#253445] rounded-lg mt-4 mx-2 px-4 py-4 shadow-2xl overflow-y-auto md:row-span-1",
className
)}
>

View File

@@ -1,23 +1,28 @@
import type { IconProp } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import clsx from "clsx";
type CameraOverviewHeaderProps = {
title: string;
icon?: IconProp;
img?: string;
};
const CardHeader = ({ title, icon }: CameraOverviewHeaderProps) => {
const CardHeader = ({ title, icon, img }: CameraOverviewHeaderProps) => {
return (
<div
className={clsx(
"w-full border-b border-gray-600 flex flex-row items-center space-x-2 md:mb-6 relative"
)}
>
<div className="flex items-center space-x-2">
{icon && <FontAwesomeIcon icon={icon} className="size-4" />}
<h2 className="text-xl">{title}</h2>
</div>
{img && (
<img src={img} alt="Logo" width={100} height={50} className="ml-auto" />
)}
</div>
);
};

View File

@@ -1,21 +1,82 @@
import * as React from "react";
import { Link } from "react-router";
import Logo from "/MAV.svg";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSliders } from "@fortawesome/free-solid-svg-icons";
import { faGear, faListCheck } from "@fortawesome/free-solid-svg-icons";
import type { VersionFieldType } from "../../types/types";
async function fetchVersions(signal?: AbortSignal): Promise<VersionFieldType> {
const res = await fetch("http://192.168.75.11/api/versions", { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
const pad = (n: number) => String(n).padStart(2, "0");
const normalizeToMs = (ts: number) => (ts < 1e12 ? ts * 1000 : ts); // seconds → ms if needed
function formatFromMs(ms: number, tz: "local" | "utc" = "local") {
const d = new Date(ms);
const h = tz === "utc" ? d.getUTCHours() : d.getHours();
const m = tz === "utc" ? d.getUTCMinutes() : d.getMinutes();
const s = tz === "utc" ? d.getUTCSeconds() : d.getSeconds();
const day = tz === "utc" ? d.getUTCDate() : d.getDate();
const month = (tz === "utc" ? d.getUTCMonth() : d.getMonth()) + 1;
const year = tz === "utc" ? d.getUTCFullYear() : d.getFullYear();
return `${pad(h)}:${pad(m)}:${pad(s)} ${pad(day)}-${pad(month)}-${year}`;
}
export default function Header() {
const [offsetMs, setOffsetMs] = React.useState<number | null>(null);
const [nowMs, setNowMs] = React.useState<number>(Date.now());
React.useEffect(() => {
const ac = new AbortController();
fetchVersions(ac.signal)
.then((data) => {
const serverMs = normalizeToMs(data.timeStamp);
setOffsetMs(serverMs - Date.now());
})
return () => ac.abort();
}, []);
React.useEffect(() => {
let timer: number;
const schedule = () => {
const now = Date.now();
setNowMs(now);
const delay = 1000 - (now % 1000);
timer = window.setTimeout(schedule, delay);
};
schedule();
return () => clearTimeout(timer);
}, []);
const serverNowMs = offsetMs == null ? nowMs : nowMs + offsetMs;
const localStr = formatFromMs(serverNowMs, "local");
const utcStr = formatFromMs(serverNowMs, "utc");
const Header = () => {
return (
<div className="relative bg-[#253445] border-b border-gray-500 items-center mx-auto px-2 sm:px-6 lg:px-8 p-4 flex flex-row justify-between">
{/* Left: Logo */}
<div className="w-30">
<Link to={"/"}>
<img src={Logo} alt="Logo" width={100} height={100} />
<img src={Logo} alt="Logo" width={150} height={150} />
</Link>
</div>
<Link to={"/system-settings"}>
<FontAwesomeIcon icon={faSliders} />
{/* Right: Texts stacked + icons */}
<div className="flex items-center space-x-12">
<div className="flex flex-col leading-tight text-white text-sm tabular-nums">
<h2>Local: {localStr}</h2>
<h2>UTC: {utcStr}</h2>
</div>
<Link to={"/session-settings"}>
<FontAwesomeIcon className="text-white" icon={faListCheck} />
</Link>
<Link to={"/system-settings"}>
<FontAwesomeIcon className="text-white" icon={faGear} />
</Link>
</div>
</div>
);
};
export default Header;
}

View File

@@ -26,7 +26,7 @@ const NavigationArrow = ({ side, settingsPage }: NavigationArrowProps) => {
if (settingsPage) {
return (
<>
{side === "TargetDetectionFront" ? (
{side === "CameraFront" ? (
<FontAwesomeIcon
icon={faArrowRight}
className="absolute top-[50%] right-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce"

View File

@@ -41,7 +41,7 @@ export function useGetOverviewSnapshot(cameraSide: string) {
queryKey: ["overviewSnapshot", cameraSide],
queryFn: () => fetchSnapshot(cameraSide),
refetchOnWindowFocus: false,
// refetchInterval: 1000,
refetchInterval: 250,
});
useEffect(() => {

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef } from "react";
async function fetchSighting() {
const response = await fetch(
// `http://100.82.205.44/api`
`http://100.116.253.81/mergedHistory/sightingSummary?mostRecentRef=-1`
`http://192.168.75.11/mergedHistory/sightingSummary?mostRecentRef=-1`
);
if (!response.ok) throw new Error("Failed to fetch sighting");
return response.json();
@@ -31,7 +31,7 @@ export function useLatestSighting() {
const { data } = useQuery({
queryKey: ["latestSighting"],
queryFn: fetchSighting,
refetchInterval: 500,
refetchInterval: 100,
});
useEffect(() => {

View File

@@ -1,86 +1,70 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { SightingWidgetType } from "../types/types";
import { useQuery } from "@tanstack/react-query";
// const url = `http://100.82.205.44/SightingListFront/sightingSummary?mostRecentRef=-1`;
async function fetchSighting(url: string, ref: number, signal?: AbortSignal) {
const dynamicUrl = `${url}${ref}`;
const res = await fetch(dynamicUrl, { signal });
async function fetchSighting(url: string, ref: number): Promise<SightingWidgetType> {
const res = await fetch(`${url}${ref}`);
if (!res.ok) throw new Error(String(res.status));
return (await res.json()) as SightingWidgetType;
return await res.json();
}
export function useSightingFeed(url: string) {
const [sightings, setSightings] = useState<SightingWidgetType[]>(
() => Array(7).fill(null) as unknown as SightingWidgetType[]
);
const [noSighting, setNoSighting] = useState(false);
const [sightings, setSightings] = useState<SightingWidgetType[]>([]);
const [selectedRef, setSelectedRef] = useState<number | null>(null);
const [mostRecent, setMostRecent] = useState<SightingWidgetType | null>(null);
const mostRecentRef = useRef<number>(-1);
const lastSeenRef = useRef<number | null>(null);
const { data, isPending } = useQuery({
queryKey: ["sighting"],
queryFn: ({ signal }) => fetchSighting(url, mostRecentRef.current, signal),
refetchInterval: 2000,
refetchIntervalInBackground: true,
refetchOnWindowFocus: false,
staleTime: 0,
notifyOnChangeProps: ["data"],
});
const currentRef = useRef<number>(-1);
const pollingTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastValidTimestamp = useRef<number>(Date.now());
useEffect(() => {
if (!data) return;
const poll = async () => {
try {
const data = await fetchSighting(url, currentRef.current);
const now = Date.now();
if (data.ref === -1) {
setNoSighting(true);
if (now - lastValidTimestamp.current > 60000) {
console.warn("No valid sighting in over a minute. Restarting...");
currentRef.current = -1;
lastValidTimestamp.current = now;
}
pollingTimeout.current = setTimeout(poll, 400);
} else {
setNoSighting(false);
}
if (data.ref === lastSeenRef.current) return; // duplicate payload → do nothing
lastSeenRef.current = data.ref;
currentRef.current = data.ref;
lastValidTimestamp.current = now;
setSightings((prev) => {
const existing = prev.find((p) => p?.ref === data.ref);
const next = existing
? prev
: [data, ...prev.filter(Boolean)].slice(0, 7);
const stillHasSelection =
selectedRef != null && next.some((s) => s?.ref === selectedRef);
if (!stillHasSelection) {
setSelectedRef(data.ref);
}
return next;
setSightings(prev => {
const updated = [data, ...prev].slice(0, 7);
return updated;
});
setMostRecent(sightings[0]);
mostRecentRef.current = data.ref ?? -1;
}, [data, selectedRef, sightings]);
const selected = useMemo(
() =>
selectedRef == null
? null
: sightings.find((s) => s?.ref === selectedRef) ?? null,
[sightings, selectedRef]
);
setMostRecent(data);
setSelectedRef(data.ref);
const effectiveSelected = selected ?? mostRecent ?? null;
pollingTimeout.current = setTimeout(poll, 100);
}
} catch (err) {
console.error("Polling error:", err);
pollingTimeout.current = setTimeout(poll, 100);
}
};
poll();
return () => {
if (pollingTimeout.current) clearTimeout(pollingTimeout.current);
};
}, [url]);
const selected = sightings.find(s => s?.ref === selectedRef) ?? mostRecent;
return {
sightings,
selectedRef,
setSelectedRef,
mostRecent,
effectiveSelected,
mostRecentRef,
isPending,
noSighting,
effectiveSelected: selected,
};
}

View File

@@ -6,11 +6,10 @@ import { SightingFeedProvider } from "../context/providers/SightingFeedProvider"
const Dashboard = () => {
return (
<div className="mx-auto grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-2 gap-2 px-2 sm:px-4 lg:px-0 w-full">
<div className="mx-auto grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-2 gap-2 px-1 sm:px-2 lg:px-0 w-full">
<SightingFeedProvider
url={
"http://100.82.205.44/SightingListFront/sightingSummary?mostRecentRef="
// "http://100.116.253.81/mergedHistory/sightingSummary?mostRecentRef="
"http://192.168.75.11/SightingListFront/sightingSummary?mostRecentRef="
}
side="Front"
>
@@ -19,7 +18,7 @@ const Dashboard = () => {
</SightingFeedProvider>
<SightingFeedProvider
url="http://100.82.205.44/SightingListRear/sightingSummary?mostRecentRef="
url="http://192.168.75.11/SightingListRear/sightingSummary?mostRecentRef="
side="Rear"
>
<RearCameraOverviewCard className="order-2" />

View File

@@ -18,7 +18,7 @@ const FrontCamera = () => {
>
<OverviewVideoContainer
title={"Front Camera"}
side="TargetDetectionFront"
side="CameraFront"
settingsPage={true}
/>
<CameraSettings title="Front Camera Settings" />

View File

@@ -19,7 +19,7 @@ const RearCamera = () => {
<CameraSettings title="Rear Camera Settings" />
<OverviewVideoContainer
title={"Rear Camera"}
side={"TargetDetectionRear"}
side={"CameraRear"}
settingsPage={true}
/>
<Toaster />

13
src/pages/Session.tsx Normal file
View File

@@ -0,0 +1,13 @@
import HitSearchCard from "../components/SessionForm/HitSearchCard.tsx";
import SessionCard from "../components/SessionForm/SessionCard.tsx";
const Session = () => {
return (
<div className="mx-auto grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-2 gap-2 px-1 sm:px-2 lg:px-0 w-full">
<HitSearchCard />
<SessionCard />
</div>
);
};
export default Session;

View File

@@ -3,6 +3,9 @@ import "react-tabs/style/react-tabs.css";
import NPEDCard from "../components/SettingForms/NPED/NPEDCard";
import SettingForms from "../components/SettingForms/SettingForms/SettingForms";
import NPEDHotlistCard from "../components/SettingForms/NPED/NPEDHotlistCard";
import WiFiCard from "../components/SettingForms/WiFi&Modem/WiFiCard";
import ModemCard from "../components/SettingForms/WiFi&Modem/ModemCard";
import SystemCard from "../components/SettingForms/System/SystemCard";
import { Toaster } from "sonner";
import { useNPEDAuth } from "../hooks/useNPEDAuth";
@@ -13,9 +16,16 @@ const SystemSettings = () => {
<div className="m-4">
<Tabs selectedTabClassName="bg-gray-300 text-gray-900 font-semibold border-none">
<TabList>
<Tab>System</Tab>
<Tab>Output</Tab>
<Tab>Integrations</Tab>
<Tab>WiFi and Modem</Tab>
</TabList>
<TabPanel>
<div className="flex flex-col space-y-3">
<SystemCard />
</div>
</TabPanel>
<TabPanel>
<div className="flex flex-col space-y-3">
<SettingForms />
@@ -27,6 +37,12 @@ const SystemSettings = () => {
<NPEDHotlistCard />
</div>
</TabPanel>
<TabPanel>
<div className="mx-auto grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-2 gap-4 px-2 sm:px-4 lg:px-0 w-full">
<WiFiCard />
<ModemCard />
</div>
</TabPanel>
</Tabs>
<Toaster />
</div>

View File

@@ -102,6 +102,18 @@ export type SightingWidgetType = {
// list of rects normalized 0..1
};
export type VersionFieldType = {
version: string;
revision: string;
buildtime: string;
MAC: string;
timeStamp: number;
UUID: string;
"Serial No.": string;
"Model No.": string;
};
export type Metadata = {
npedJSON: NpedJSON;
"hotlist-matches": HotlistMatches;

906
yarn.lock

File diff suppressed because it is too large Load Diff