Implement UI for managing multiple models (#24023)

* Implement hardware detection and UI management

* Cleanup Frigate+ detection

* Don't count model as changed

* Fixes for audio map error

* Add descriptions

* Enforce that all model must exist

* Fix hardware picking

* Docs fixes

* WebUI cleanup

* Cleanup handling of scenes

* UI refinement

* Cleanup recommended UI

* test fixews
This commit is contained in:
Nicolas Mowen
2026-08-22 11:40:42 -05:00
committed by Josh Hawkins
parent 79ea68caa2
commit 07ba2357e6
59 changed files with 2554 additions and 2587 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+39
View File
@@ -0,0 +1,39 @@
/**
* Detection hardware as reported by GET /api/hardware/probe.
*
* A mixed payload on purpose: two Corals exercise the per-unit checkboxes and
* the "already used by another model" state, while the Intel GPU exercises the
* unlimited detector-count dropdown.
*/
export const DETECTION_HARDWARE = [
{
key: "edgetpu:pci",
detector: "edgetpu",
name: "Coral EdgeTPU (PCIe)",
units: [
{ device: "edgetpu:pci:0", label: "PCIe 0" },
{ device: "edgetpu:pci:1", label: "PCIe 1" },
],
count: 2,
unlimited: false,
},
{
key: "openvino:GPU",
detector: "openvino",
name: "Intel GPU",
units: [
{ device: "openvino:GPU.0", label: "0000:00:02.0" },
{ device: "openvino:GPU.1", label: "0000:03:00.0" },
],
count: 2,
unlimited: true,
},
{
key: "cpu",
detector: "cpu",
name: "CPU",
units: [{ device: "cpu", label: "CPU" }],
count: 1,
unlimited: true,
},
];
+7
View File
@@ -14,6 +14,7 @@ import {
type DeepPartial,
configFactory,
} from "../fixtures/mock-data/config";
import { DETECTION_HARDWARE } from "../fixtures/mock-data/hardware";
import { adminProfile, type UserProfile } from "../fixtures/mock-data/profile";
import { BASE_STATS, statsFactory } from "../fixtures/mock-data/stats";
@@ -41,6 +42,7 @@ export interface ApiMockOverrides {
faces?: Record<string, unknown>;
configRaw?: string;
configSchema?: Record<string, unknown>;
hardware?: unknown[];
}
export class ApiMocker {
@@ -178,6 +180,11 @@ export class ApiMocker {
route.fulfill({ json: { success: true, require_restart: false } }),
);
// Detection hardware discovery
await this.page.route("**/api/hardware/probe**", (route) =>
route.fulfill({ json: overrides?.hardware ?? DETECTION_HARDWARE }),
);
// Go2RTC streams
await this.page.route("**/api/go2rtc/streams**", (route) =>
route.fulfill({ json: {} }),
@@ -0,0 +1,404 @@
/**
* Detection models settings page tests -- HIGH tier.
*
* Covers picking hardware per model: exclusive units (Corals) are checkboxes
* that can only be claimed by one model, unlimited hardware (a GPU) gets a
* detector-count dropdown, and the whole models list saves in one PUT.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import { configFactory } from "../../fixtures/mock-data/config";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_SCHEMA = JSON.parse(
readFileSync(
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
"utf-8",
),
);
const PAGE = "/settings?page=systemDetectorsAndModel";
type Model = {
scene: string;
devices: string[];
path?: string | null;
input_tensor?: string;
input_pixel_format?: string;
input_dtype?: string;
model_type?: string;
labelmap?: Record<string, string>;
attributes_map?: Record<string, string[]>;
plus?: { id: string; name: string } | null;
width?: number;
height?: number;
};
const PLUS_MODEL = {
id: "abc123",
name: "yolov9-s",
baseModel: "yolov9",
trainDate: "2026-01-02T03:04:05Z",
isBaseModel: true,
supportedDetectors: ["openvino"],
width: 320,
height: 320,
};
type SavedConfig = { config_data?: { models?: Model[] } };
async function installRoutes(page: Page, models: Model[], plusEnabled = false) {
const config = configFactory({
models,
plus: { enabled: plusEnabled },
} as never);
const saves: SavedConfig[] = [];
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config", (route) =>
route.request().method() === "GET"
? route.fulfill({ json: config })
: route.fulfill({ json: { success: true } }),
);
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({ json: { models } }),
);
await page.route("**/api/plus/models", (route) =>
route.fulfill({ json: [PLUS_MODEL] }),
);
await page.route("**/api/config/set", async (route) => {
saves.push(route.request().postDataJSON() as SavedConfig);
await route.fulfill({ json: { success: true, require_restart: false } });
});
return saves;
}
const openPage = async (frigateApp: {
goto: (url: string) => Promise<void>;
}) => {
await frigateApp.goto(PAGE);
};
test.describe("Detection models settings @high", () => {
test("renders a card per configured model", async ({ frigateApp }) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["cpu"] },
{ scene: "outdoor", devices: ["edgetpu:pci:0"] },
]);
await openPage(frigateApp);
const root = frigateApp.page.locator("#pageRoot");
await expect(root).toContainText("All cameras");
await expect(root).toContainText("Outdoor");
});
test("unlimited hardware offers a detector count", async ({ frigateApp }) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0"] },
]);
await openPage(frigateApp);
await expect(
frigateApp.page.getByText("Detectors", { exact: true }),
).toBeVisible();
// the trigger shows the bare count; the recommendation is a second line on
// the matching option, so the dropdown has to be open to see it
await expect(
frigateApp.page.locator("#models-0-detector-count"),
).toHaveText("1");
await frigateApp.page.locator("#models-0-detector-count").click();
// three cameras in the mock config, so one detector is recommended
await expect(
frigateApp.page.getByRole("option", {
name: /Recommended for 3 cameras/,
}),
).toHaveText(/^1/);
});
test("a detector count above the recommendation is unlabelled", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0", "openvino:GPU.0"] },
]);
await openPage(frigateApp);
// two detectors are configured while one is recommended, so neither the
// trigger nor the selected option carries a recommendation
await expect(
frigateApp.page.locator("#models-0-detector-count"),
).toHaveText("2");
await frigateApp.page.locator("#models-0-detector-count").click();
await expect(
frigateApp.page.getByRole("option", { name: /^2/ }),
).not.toContainText("Recommended");
});
test("exclusive hardware offers one checkbox per unit", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["edgetpu:pci:0"] },
]);
await openPage(frigateApp);
await expect(
frigateApp.page.locator("#models-0-edgetpu\\:pci\\:0"),
).toBeChecked();
await expect(
frigateApp.page.locator("#models-0-edgetpu\\:pci\\:1"),
).not.toBeChecked();
});
test("a unit claimed by another model cannot be picked", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["edgetpu:pci:0"] },
{ scene: "outdoor", devices: ["edgetpu:pci:1"] },
]);
await openPage(frigateApp);
// the first card's checkbox for the unit the second model holds
await expect(
frigateApp.page.locator("#models-0-edgetpu\\:pci\\:1").first(),
).toBeDisabled();
});
test("adding a model appends a card with an unused scene", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [{ scene: "all", devices: ["cpu"] }]);
await openPage(frigateApp);
await frigateApp.page.getByRole("button", { name: "Add model" }).click();
// "all" is taken, so the new card takes the next available scene
await expect(frigateApp.page.locator("#pageRoot")).toContainText("Indoor");
});
test("hardware is summarized rather than listed device by device", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0", "openvino:GPU.0"] },
]);
await openPage(frigateApp);
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"Intel GPU (2) \u2022 3 cameras",
);
await expect(frigateApp.page.locator("#pageRoot")).not.toContainText(
"openvino:GPU, openvino:GPU",
);
});
test("a saved Frigate+ model opens on the Frigate+ tab", async ({
frigateApp,
}) => {
// the backend resolves plus:// to a cache path before serving the config
// back, so the plus metadata is the only signal the model is a Plus one
await installRoutes(
frigateApp.page,
[
{
scene: "all",
devices: ["openvino:GPU.0"],
path: "/config/model_cache/abc123",
plus: PLUS_MODEL,
},
],
true,
);
await openPage(frigateApp);
await expect(
frigateApp.page.getByRole("tab", { name: "Frigate+" }),
).toHaveAttribute("data-state", "active");
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"yolov9-s",
);
});
test("picking a Frigate+ model stays on the tab and saves a plus path", async ({
frigateApp,
}) => {
const saves = await installRoutes(
frigateApp.page,
[
{
scene: "all",
devices: ["openvino:GPU.0"],
path: "/config/custom.onnx",
},
],
true,
);
await openPage(frigateApp);
await frigateApp.page.getByRole("tab", { name: "Frigate+" }).click();
await frigateApp.page.getByRole("combobox").last().click();
await frigateApp.page.getByRole("option").first().click();
await expect(
frigateApp.page.getByRole("tab", { name: "Frigate+" }),
).toHaveAttribute("data-state", "active");
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
await expect.poll(() => saves.length).toBeGreaterThan(0);
expect(saves.at(-1)?.config_data?.models?.[0].path).toBe("plus://abc123");
});
test("a freshly opened page is not reported as modified", async ({
frigateApp,
}) => {
// `/api/config` serializes with exclude_none, so a nullable field such as
// labelmap_path is absent rather than null. The form materializes it, and
// that must not read as an edit.
await installRoutes(frigateApp.page, [
{
scene: "all",
devices: ["openvino:GPU.0", "openvino:GPU.0"],
path: "/config/model_cache/abc123",
width: 320,
height: 320,
input_tensor: "nchw",
input_pixel_format: "rgb",
input_dtype: "float",
model_type: "yolo-generic",
labelmap: {},
attributes_map: {},
},
]);
await openPage(frigateApp);
await expect(
frigateApp.page.getByRole("button", { name: /^Save$/ }),
).toBeVisible();
await expect(frigateApp.page.getByText("Modified")).toHaveCount(0);
});
test("the scene, hardware and detector count fields are described", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0"] },
]);
await openPage(frigateApp);
const root = frigateApp.page.locator("#pageRoot");
await expect(root).toContainText("The environment this model is for");
await expect(root).toContainText(
"The hardware this model runs its detection on",
);
await expect(root).toContainText("How many detection processes to run");
});
test("per unit hardware explains why a claimed unit is unavailable", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["edgetpu:pci:0"] },
]);
await openPage(frigateApp);
// the count dropdown is replaced by checkboxes, so it gets its own copy
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"Each unit runs its own detection process",
);
});
test("removing the default model blocks saving", async ({ frigateApp }) => {
// a camera that names no scene runs the "all" model, so deleting it would
// leave those cameras with nothing to fall back to
await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["cpu"] },
{ scene: "outdoor", devices: ["edgetpu:pci:0"] },
]);
await openPage(frigateApp);
await frigateApp.page
.getByRole("button", { name: "Delete" })
.first()
.click();
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"One model must use a scene of 'All cameras'",
);
await expect(
frigateApp.page.getByRole("button", { name: /^Save$/ }),
).toBeDisabled();
});
test("a second GPU can be assigned to a model", async ({ frigateApp }) => {
// shareable hardware can report several addressable units; every one of
// them must be reachable, not just the first
const saves = await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0"] },
]);
await openPage(frigateApp);
await frigateApp.page.locator("#models-0-openvino\\:GPU\\.1").click();
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
await expect.poll(() => saves.length).toBeGreaterThan(0);
expect(saves.at(-1)?.config_data?.models?.[0].devices).toEqual([
"openvino:GPU.0",
"openvino:GPU.1",
]);
});
test("detectors are spread across every selected GPU", async ({
frigateApp,
}) => {
const saves = await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0", "openvino:GPU.1"] },
]);
await openPage(frigateApp);
await frigateApp.page.locator("#models-0-detector-count").click();
await frigateApp.page
.getByRole("option", { name: "4", exact: true })
.click();
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
await expect.poll(() => saves.length).toBeGreaterThan(0);
expect(saves.at(-1)?.config_data?.models?.[0].devices).toEqual([
"openvino:GPU.0",
"openvino:GPU.1",
"openvino:GPU.0",
"openvino:GPU.1",
]);
});
test("saving writes the whole models list in one request", async ({
frigateApp,
}) => {
const saves = await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["edgetpu:pci:0"] },
]);
await openPage(frigateApp);
await frigateApp.page.locator("#models-0-edgetpu\\:pci\\:1").click();
await frigateApp.page.getByRole("button", { name: /^Save$/ }).click();
await expect.poll(() => saves.length).toBeGreaterThan(0);
const models = saves.at(-1)?.config_data?.models;
expect(models).toHaveLength(1);
expect(models?.[0].devices).toEqual(["edgetpu:pci:0", "edgetpu:pci:1"]);
});
});
@@ -1,58 +0,0 @@
/**
* Detectors and model settings page tests -- HIGH tier.
*
* Tests rendering of the merged page and navigation from the Frigate+ page.
*/
import { test, expect } from "../../fixtures/frigate-test";
// The settings page still reads the removed `detectors` and `model` config
// keys, so it cannot render against a `models` config. Re-enable these once
// the page is rebuilt around the models list.
test.describe.skip("Detectors and model Settings @high", () => {
test("page renders with detector and model cards", async ({ frigateApp }) => {
await frigateApp.goto("/settings?page=systemDetectorsAndModel");
await frigateApp.page.waitForTimeout(2000);
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
const text = await frigateApp.page.textContent("#pageRoot");
expect(text).toContain("Detectors and model");
expect(text?.toLowerCase()).toContain("detector hardware");
expect(text?.toLowerCase()).toContain("detection model");
});
test("Frigate+ page links to the merged page", async ({ frigateApp }) => {
await frigateApp.goto("/settings?page=frigateplus");
await frigateApp.page.waitForTimeout(2000);
const button = frigateApp.page.getByRole("button", {
name: /Change in Detectors and model/,
});
// Button only appears when Frigate+ is enabled in the test config; skip
// the click assertion if it's not present.
if ((await button.count()) > 0) {
await button.first().click();
await frigateApp.page.waitForURL(/page=systemDetectorsAndModel/);
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"Detectors and model",
);
} else {
test.skip(
true,
"Frigate+ not enabled in this test config; skipping link assertion",
);
}
});
test("old systemDetectionModel deep-link no longer routes here", async ({
frigateApp,
}) => {
await frigateApp.goto("/settings?page=systemDetectionModel");
await frigateApp.page.waitForTimeout(2000);
// The old page key is no longer in allSettingsViews; the router
// falls back to its default settings page (uiSettings).
const text = await frigateApp.page.textContent("#pageRoot");
expect(text).not.toContain("Detection model");
});
});