mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 19:58:57 +03:00
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:
committed by
Josh Hawkins
parent
79ea68caa2
commit
07ba2357e6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user