Merged in bugfix/output (pull request #10)

Bugfix/output
This commit is contained in:
2025-10-07 13:29:01 +00:00
8 changed files with 391 additions and 166 deletions

View File

@@ -1,16 +1,43 @@
import { Field, useFormikContext } from "formik"; import { Field, Form, Formik } from "formik";
import FormToggle from "../components/FormToggle"; import FormToggle from "../components/FormToggle";
import { useCameraOutput } from "../../../hooks/useCameraOutput";
import { cleanArray } from "../../../utils/utils";
import FormGroup from "../components/FormGroup";
import type { BearerTypeFieldType } from "../../../types/types";
export const ValuesComponent = () => { export const ValuesComponent = () => {
return null; return null;
}; };
const BearerTypeFields = () => { const BearerTypeFields = () => {
useFormikContext(); const { dispatcherQuery, dispatcherMutation } = useCameraOutput();
const format = dispatcherQuery?.data?.propFormat?.value;
const rawOptions = dispatcherQuery?.data?.propFormat?.accepted;
const enabled = dispatcherQuery?.data?.propEnabled?.value;
const verbose = dispatcherQuery?.data?.propVerbose?.value;
const options = cleanArray(rawOptions);
const initialValues: BearerTypeFieldType = {
format: format ?? "JSON",
enabled: enabled === "true",
verbose: verbose === "true",
};
const handleSubmit = async (values: BearerTypeFieldType) => {
await dispatcherMutation.mutateAsync(values);
};
return ( return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
enableReinitialize
>
{({ isSubmitting }) => (
<Form>
<div className="flex flex-col space-y-4 px-2"> <div className="flex flex-col space-y-4 px-2">
<div className="flex items-center gap-3 justify-between"> <FormGroup>
<label htmlFor="format">Format</label> <label htmlFor="format">Format</label>
<Field <Field
as="select" as="select"
@@ -18,15 +45,31 @@ const BearerTypeFields = () => {
id="format" id="format"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] w-full md:w-60" className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] w-full md:w-60"
> >
<option value="JSON">JSON</option> {options?.map((option: string) => (
<option value="BOF2">BOF2</option> <option key={option} value={option}>
{option}
</option>
))}
</Field> </Field>
</div> </FormGroup>
<FormGroup>
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
<FormToggle name="enabled" label="Enabled" /> <FormToggle name="enabled" label="Enabled" />
<FormToggle name="verbose" label="Verbose" /> <FormToggle name="verbose" label="Verbose" />
</div> </div>
</FormGroup>
<button
type="submit"
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full md:w-[50%]"
>
{isSubmitting || dispatcherMutation.isPending
? "Saving..."
: "Save Changes"}
</button>
</div> </div>
</Form>
)}
</Formik>
); );
}; };

View File

@@ -1,24 +1,107 @@
import { Field, useFormikContext } from "formik"; import { Field, Form, Formik, useFormikContext } from "formik";
import FormGroup from "../components/FormGroup"; import FormGroup from "../components/FormGroup";
import { useState } from "react"; import { useEffect, useState } from "react";
import { faEyeSlash, faEye } from "@fortawesome/free-solid-svg-icons"; import { faEyeSlash, faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useCameraOutput } from "../../../hooks/useCameraOutput";
import type {
InitialValuesForm,
InitialValuesFormErrors,
} from "../../../types/types";
import { toast } from "sonner";
const ChannelFields = () => { const ChannelFields = () => {
useFormikContext();
const [showPwd, setShowPwd] = useState(false); const [showPwd, setShowPwd] = useState(false);
const { backOfficeQuery, backOfficeMutation } = useCameraOutput();
const backOfficeURL = backOfficeQuery?.data?.propBackofficeURL?.value;
const username = backOfficeQuery?.data?.propUsername?.value;
const password = backOfficeQuery?.data?.propPassword?.value;
const connectTimeoutSeconds =
backOfficeQuery?.data?.propConnectTimeoutSeconds?.value;
const readTimeoutSeconds =
backOfficeQuery?.data?.propReadTimeoutSeconds?.value;
const initialValues: InitialValuesForm = {
backOfficeURL: backOfficeURL ?? "",
username: username ?? "",
password: password ?? "",
connectTimeoutSeconds: Number(connectTimeoutSeconds),
readTimeoutSeconds: Number(readTimeoutSeconds),
};
const handleSubmit = async (values: InitialValuesForm) => {
await backOfficeMutation.mutateAsync(values);
};
const ValidationToastOnce = () => {
const { submitCount, isValid } = useFormikContext();
useEffect(() => {
if (submitCount > 0 && !isValid) {
toast.error("Check fields are filled in");
}
}, [submitCount, isValid]);
return null;
};
const validateValues = (
values: InitialValuesForm
): InitialValuesFormErrors => {
const errors: InitialValuesFormErrors = {};
const url = values.backOfficeURL?.trim();
const username = values.username?.trim();
const password = values.password?.trim();
if (!url) {
errors.backOfficeURL = "Required";
}
if (!username) errors.username = "Required";
if (!password) errors.password = "Required";
const read = Number(values.readTimeoutSeconds);
if (!Number.isFinite(read)) {
errors.readTimeoutSeconds = "Must be a number";
} else if (read < 0) {
errors.readTimeoutSeconds = "Must be ≥ 0";
}
const connect = Number(values.connectTimeoutSeconds);
if (!Number.isFinite(connect)) {
errors.connectTimeoutSeconds = "Must be a number";
} else if (connect < 0) {
errors.connectTimeoutSeconds = "Must be ≥ 0";
}
return errors;
};
return ( return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
enableReinitialize
validate={validateValues}
>
{({ errors, touched, isSubmitting }) => (
<Form>
<div className="flex flex-col space-y-2 px-2"> <div className="flex flex-col space-y-2 px-2">
<FormGroup> <FormGroup>
<label htmlFor="backoffice" className="m-0"> <label htmlFor="backoffice" className="m-0">
Back Office URL Back Office URL
</label> </label>
<Field <Field
name={"backOfficeURL"} name={"backOfficeURL"}
type="text" type="text"
id="backoffice" id="backoffice"
placeholder="https://www.backoffice.com" placeholder="https://www.backoffice.com"
className="p-1.5 border border-gray-400 rounded-lg w-full md:w-60" className={`p-1.5 border ${
errors.backOfficeURL && touched.backOfficeURL
? "border-red-500"
: "border-gray-400 "
} rounded-lg w-full md:w-60`}
/> />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
@@ -28,7 +111,11 @@ const ChannelFields = () => {
type="text" type="text"
id="username" id="username"
placeholder="Back office username" placeholder="Back office username"
className="p-1.5 border border-gray-400 rounded-lg w-full md:w-60" className={`p-1.5 border ${
errors.username && touched.username
? "border-red-500"
: "border-gray-400 "
} rounded-lg w-full md:w-60`}
/> />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
@@ -38,7 +125,11 @@ const ChannelFields = () => {
type={showPwd ? "text" : "password"} type={showPwd ? "text" : "password"}
id="password" id="password"
placeholder="Back office password" placeholder="Back office password"
className="p-1.5 border border-gray-400 rounded-lg w-full md:w-60" className={`p-1.5 border ${
errors.password && touched.password
? "border-red-500"
: "border-gray-400 "
} rounded-lg w-full md:w-60`}
/> />
<FontAwesomeIcon <FontAwesomeIcon
type="button" type="button"
@@ -48,12 +139,18 @@ const ChannelFields = () => {
/> />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<label htmlFor="connectTimeoutSeconds">Connect Timeout Seconds</label> <label htmlFor="connectTimeoutSeconds">
Connect Timeout Seconds
</label>
<Field <Field
name={"connectTimeoutSeconds"} name={"connectTimeoutSeconds"}
type="number" type="number"
id="connectTimeoutSeconds" id="connectTimeoutSeconds"
className="p-1.5 border border-gray-400 rounded-lg w-full md:w-60" className={`p-1.5 border ${
errors.connectTimeoutSeconds && touched.connectTimeoutSeconds
? "border-red-500"
: "border-gray-400 "
} rounded-lg w-full md:w-60`}
/> />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
@@ -63,10 +160,26 @@ const ChannelFields = () => {
type="number" type="number"
id="readTimeoutSeconds" id="readTimeoutSeconds"
placeholder="https://example.com" placeholder="https://example.com"
className="p-1.5 border border-gray-400 rounded-lg w-full md:w-60" className={`p-1.5 border ${
errors.readTimeoutSeconds && touched.readTimeoutSeconds
? "border-red-500"
: "border-gray-400 "
} rounded-lg w-full md:w-60`}
/> />
</FormGroup> </FormGroup>
</div> </div>
<button
type="submit"
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full md:w-[50%]"
>
{isSubmitting || backOfficeMutation.isPending
? "Saving..."
: "Save Changes"}
</button>
<ValidationToastOnce />
</Form>
)}
</Formik>
); );
}; };

View File

@@ -1,10 +1,8 @@
import { Field, useFormikContext } from "formik"; import { Field } from "formik";
import FormGroup from "../components/FormGroup"; import FormGroup from "../components/FormGroup";
import FormToggle from "../components/FormToggle"; import FormToggle from "../components/FormToggle";
const OverviewTextFields = () => { const OverviewTextFields = () => {
useFormikContext();
return ( return (
<div className="flex flex-col space-y-2 px-2"> <div className="flex flex-col space-y-2 px-2">
<FormGroup> <FormGroup>

View File

@@ -1,83 +1,12 @@
import { Formik, Form } from "formik";
import BearerTypeCard from "../BearerType/BearerTypeCard"; import BearerTypeCard from "../BearerType/BearerTypeCard";
import ChannelCard from "../Channel1-JSON/ChannelCard"; import ChannelCard from "../Channel1-JSON/ChannelCard";
import type { InitialValuesForm } from "../../../types/types";
import { useState } from "react";
import AdvancedToggle from "../../UI/AdvancedToggle";
import OverviewTextCard from "../OverviewText/OverviewTextCard";
import SightingDataCard from "../SightingData/SightingDataCard";
const SettingForms = () => { const SettingForms = () => {
const [advancedToggle, setAdvancedToggle] = useState(false);
const initialValues = {
format: "JSON",
enabled: false,
verbose: false,
backOfficeURL: "",
username: "",
password: "",
connectTimeoutSeconds: 0,
readTimeoutSeconds: 0,
overviewQuality: "high",
overviewImageScaleFactor: "full",
overviewType: "Plate Overview",
invertMotion: false,
maxPlateValueLength: 0,
vrmToTransit: "plain VRM ASCII (default)",
staticReadAction: "Use Lane Direction",
noRegionAction: "send",
countryCodeType: "IBAN 2 Character code (default)",
filterMinConfidence: 0,
filterMaxConfidence: 100,
overviewQualityOverride: 0,
sightingDataEnabled: false,
sighthingDataVerbose: false,
includeVRM: false,
includeMotion: false,
includeTimestamp: false,
timestampFormat: "UTC",
includeCameraName: false,
customFieldA: "",
customFieldB: "",
customFieldC: "",
customFieldD: "",
overlayPosition: "Top",
};
const handleSubmit = (values: InitialValuesForm) => {
alert(JSON.stringify(values));
};
return ( return (
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
<Form className="flex flex-col space-y-3">
<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-2 sm:px-4 lg:px-0 w-full">
<BearerTypeCard /> <BearerTypeCard />
<ChannelCard /> <ChannelCard />
</div> </div>
<AdvancedToggle
advancedToggle={advancedToggle}
onAdvancedChange={setAdvancedToggle}
/>
{advancedToggle && (
<>
<div className="md:col-span-2">
<SightingDataCard />
</div>
<div className="md:col-span-2">
<OverviewTextCard />
</div>
</>
)}
<button
type="submit"
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full md:w-[50%]"
>
Save Changes
</button>
</Form>
</Formik>
); );
}; };

View File

@@ -1,10 +1,8 @@
import { Field, useFormikContext } from "formik"; import { Field } from "formik";
import FormGroup from "../components/FormGroup"; import FormGroup from "../components/FormGroup";
import FormToggle from "../components/FormToggle"; import FormToggle from "../components/FormToggle";
const SightingDataFields = () => { const SightingDataFields = () => {
useFormikContext();
return ( return (
<div className="flex flex-col space-y-2 px-2"> <div className="flex flex-col space-y-2 px-2">
<FormGroup> <FormGroup>

View File

@@ -0,0 +1,128 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { CAM_BASE } from "../utils/config";
import { useEffect } from "react";
import { toast } from "sonner";
import type { BearerTypeFieldType, InitialValuesForm } from "../types/types";
const getDispatcherConfig = async () => {
const response = await fetch(`${CAM_BASE}/api/fetch-config?id=Dispatcher`);
if (!response.ok) throw new Error("Cannot get dispatcher configuration");
return response.json();
};
const updateDispatcherConfig = async (data: BearerTypeFieldType) => {
const updateConfigPayload = {
id: "Dispatcher",
fields: [
{
property: "propEnabled",
value: data.enabled,
},
// Todo: figure out how to add verbose conditionally
{
property: "propFormat",
value: data.format,
},
],
};
const response = await fetch(`${CAM_BASE}/api/update-config?id=Dispatcher`, {
method: "POST",
body: JSON.stringify(updateConfigPayload),
});
if (!response.ok) throw new Error("Cannot update dispatcher configuration");
return response.json();
};
const getBackOfficeConfig = async () => {
const response = await fetch(
`${CAM_BASE}/api/fetch-config?id=Dispatcher-json`
);
if (!response.ok) throw new Error("Cannot get Back Office configuration");
return response.json();
};
const updateBackOfficeConfig = async (data: InitialValuesForm) => {
const updateConfigPayload = {
id: "Dispatcher-json",
fields: [
{
property: "propBackofficeURL",
value: data.backOfficeURL,
},
{
property: "propConnectTimeoutSeconds",
value: data.connectTimeoutSeconds,
},
{
property: "propPassword",
value: data.password,
},
{
property: "propReadTimeoutSeconds",
value: data.readTimeoutSeconds,
},
{
property: "propUsername",
value: data.username,
},
],
};
const response = await fetch(
`${CAM_BASE}/api/update-config?id=Dispatcher-json`,
{
method: "POST",
body: JSON.stringify(updateConfigPayload),
}
);
if (!response.ok) throw new Error("Cannot update Back Office configuration");
return response.json();
};
export const useCameraOutput = () => {
const dispatcherQuery = useQuery({
queryKey: ["dispatcher"],
queryFn: getDispatcherConfig,
});
const backOfficeQuery = useQuery({
queryKey: ["backoffice"],
queryFn: getBackOfficeConfig,
});
const dispatcherMutation = useMutation({
mutationFn: updateDispatcherConfig,
mutationKey: ["dispatcherUpdate"],
onError: (error) => toast.error(error.message),
onSuccess: (data) => {
if (data) {
toast.success("Settings successfully updated");
}
},
});
const backOfficeMutation = useMutation({
mutationKey: ["backOfficeUpdate"],
mutationFn: updateBackOfficeConfig,
onError: (error) => toast.error(error.message),
onSuccess: (data) => {
if (data) {
toast.success("Settings successfully updated");
}
},
});
useEffect(() => {
if (dispatcherQuery.isError) toast.error(dispatcherQuery.error.message);
}, [dispatcherQuery?.error?.message, dispatcherQuery.isError]);
useEffect(() => {
if (backOfficeQuery.isError) toast.error(backOfficeQuery.error.message);
}, [backOfficeQuery?.error?.message, backOfficeQuery.isError]);
return {
dispatcherQuery,
dispatcherMutation,
backOfficeQuery,
backOfficeMutation,
};
};

View File

@@ -49,9 +49,6 @@ export type BearerTypeFieldType = {
}; };
export type InitialValuesForm = { export type InitialValuesForm = {
format: string;
enabled: boolean;
verbose: boolean;
backOfficeURL: string; backOfficeURL: string;
username: string; username: string;
password: string; password: string;
@@ -59,6 +56,14 @@ export type InitialValuesForm = {
readTimeoutSeconds: number; readTimeoutSeconds: number;
}; };
export type InitialValuesFormErrors = {
backOfficeURL?: string;
username?: string;
password?: string;
connectTimeoutSeconds?: string;
readTimeoutSeconds?: string;
};
export type NPEDFieldType = { export type NPEDFieldType = {
frontId: string; frontId: string;
username: string | undefined; username: string | undefined;

View File

@@ -19,6 +19,17 @@ const randomChars = () => {
return letter; return letter;
}; };
export function cleanArray(str: string) {
const toArr = str?.split(",");
const cleaned = toArr?.map((el: string) => {
const test = el.replace(/[^0-9a-z]/gi, "");
return test;
});
return cleaned;
}
export function parseRTSPUrl(url: string) { export function parseRTSPUrl(url: string) {
const regex = /rtsp:\/\/([^:]+):([^@]+)@([^:/]+):?(\d+)?(\/.*)?/; const regex = /rtsp:\/\/([^:]+):([^@]+)@([^:/]+):?(\d+)?(\/.*)?/;
const match = url?.match(regex); const match = url?.match(regex);