Handle form validation

This commit is contained in:
Nicolas Mowen
2026-09-29 16:12:46 -06:00
parent 267aa60001
commit 40ea80d43b
2 changed files with 92 additions and 2 deletions
@@ -286,6 +286,42 @@ test.describe("Detection models settings @high", () => {
).toBeDisabled();
});
test("two models without a path share the detector's default model", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "default", devices: ["cpu"] },
{ scene: "driveway", devices: ["cpu"] },
]);
await openPage(frigateApp);
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"The Default and driveway models use the same model file",
);
});
test("another spelling of the same path is the same model", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{
scene: "default",
devices: ["openvino:GPU.0"],
path: "/config/model_cache/yolo.onnx",
},
{
scene: "driveway",
devices: ["openvino:GPU.1"],
path: "/config//model_cache/./tmp/../yolo.onnx",
},
]);
await openPage(frigateApp);
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"The Default and driveway models use the same model file",
);
});
test("one model file may run on different detectors", async ({
frigateApp,
}) => {
@@ -3,6 +3,51 @@ import type { TFunction } from "i18next";
import { isJsonObject } from "@/lib/utils";
import { DEFAULT_SCENE, getSceneLabel } from "@/utils/modelUtil";
/** The model the backend loads for a detector when no path is configured. */
function defaultModelPath(detector: string): string | undefined {
if (detector === "cpu" || detector.endsWith("_tfl")) {
return "/cpu_model.tflite";
}
if (detector === "edgetpu") {
return "/edgetpu_model.tflite";
}
if (detector === "openvino") {
return "/openvino-model/ssdlite_mobilenet_v2.xml";
}
return undefined;
}
/**
* Collapse `//`, `.`, and `..` so spellings of one file path compare equal,
* as the backend's realpath does. Symlinks and copies can only be seen by the
* backend, which still combines them at startup.
*/
function normalizeModelPath(path: string): string {
// plus://<id> and other URLs are compared as written
if (path.includes("://")) {
return path;
}
const segments: string[] = [];
for (const segment of path.split("/")) {
if (segment === "" || segment === ".") {
continue;
}
if (segment === ".." && segments.length && segments.at(-1) !== "..") {
segments.pop();
} else if (segment !== ".." || !path.startsWith("/")) {
segments.push(segment);
}
}
return `${path.startsWith("/") ? "/" : ""}${segments.join("/")}`;
}
/**
* A camera that names no scene runs the model whose scene is `default`.
* Without one the backend rejects the config outright once a second model
@@ -38,14 +83,23 @@ export function validateModelScenes(
const seen = new Map<string, string>();
formData.forEach((model, index) => {
if (!isJsonObject(model) || typeof model.path !== "string" || !model.path) {
if (!isJsonObject(model)) {
return;
}
const devices = Array.isArray(model.devices) ? model.devices : [];
const detector =
typeof devices[0] === "string" ? devices[0].split(":")[0] : "";
const key = `${detector}|${model.path}`;
const path =
typeof model.path === "string" && model.path
? normalizeModelPath(model.path)
: defaultModelPath(detector);
if (!path) {
return;
}
const key = `${detector}|${path}`;
const other = seen.get(key);
if (other === undefined) {