Miscellaneous fixes (0.18 beta) (#23892)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s

* update network requirements docs for keras weights download

* fix manual PTZ relative moves permanently stopping object detection

* document available camera set features and link profiles docs to the API

* fix stale stream name field when switching cameras

The live streams and known plates fields rendered the map key as an uncontrolled input, so switching cameras left the previous camera's stream name on screen and would rename the wrong key if that stale text was committed. Both now use a shared MapKeyInput that resyncs with the form data and commits per keystroke, except while the typed name belongs to another entry, so the section is marked modified without waiting for blur.
This commit is contained in:
Josh Hawkins
2026-08-03 08:18:28 -05:00
committed by GitHub
parent 4f2a297745
commit 3b14ec0c87
13 changed files with 544 additions and 32 deletions
@@ -0,0 +1,204 @@
/**
* Camera live playback stream settings tests -- MEDIUM tier.
*
* The live streams field maps a display name to a go2rtc stream. Switching
* cameras from the selector keeps the form mounted and only swaps its data, so
* the stream name input has to follow the newly selected camera. It used to be
* an uncontrolled input, which left the previous camera's stream name on screen
* and renamed the wrong key if the stale text was ever committed.
*
* Renames are committed per keystroke so the section is marked as modified
* right away, except while the typed name belongs to another stream, since
* renaming onto an existing name merges the two entries.
*/
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 GO2RTC_STREAMS = {
front_door_main: ["rtsp://user:pass@192.168.0.20:554/Stream1"],
backyard_main: ["rtsp://user:pass@192.168.0.21:554/Stream1"],
};
const CAMERA_LIVE_STREAMS = {
front_door: { front_door: "front_door_main" },
backyard: { backyard: "backyard_main" },
};
const SETTINGS_URL = "/settings?page=cameraLivePlayback&camera=front_door";
async function installRoutes(
page: Page,
frontDoorStreams: Record<string, string> = CAMERA_LIVE_STREAMS.front_door,
) {
const config = configFactory({
go2rtc: { streams: GO2RTC_STREAMS },
cameras: {
front_door: { live: { streams: frontDoorStreams } },
backyard: { live: { streams: CAMERA_LIVE_STREAMS.backyard } },
},
});
let lastSavedConfig: unknown = null;
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({ json: config });
}
return route.fulfill({ json: { success: true } });
});
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({
json: {
go2rtc: { streams: GO2RTC_STREAMS },
cameras: {
front_door: { live: { streams: frontDoorStreams } },
backyard: { live: { streams: CAMERA_LIVE_STREAMS.backyard } },
},
},
}),
);
await page.route("**/api/config/set", async (route) => {
lastSavedConfig = route.request().postDataJSON();
await route.fulfill({ json: { success: true, require_restart: false } });
});
return { capturedConfig: () => lastSavedConfig };
}
async function selectCamera(page: Page, friendlyName: string) {
await page.getByRole("button", { name: "Select a camera" }).click();
await page.getByRole("switch", { name: friendlyName }).click();
}
function streamNameInputs(page: Page) {
return page.getByRole("textbox", { name: "Stream name" });
}
function streamNames(page: Page) {
return streamNameInputs(page).evaluateAll((inputs) =>
inputs.map((input) => (input as HTMLInputElement).value),
);
}
/** Rows render in config order, which is not the order they were declared in. */
async function streamNameRow(page: Page, name: string) {
await expect.poll(() => streamNames(page)).toContain(name);
const names = await streamNames(page);
return streamNameInputs(page).nth(names.indexOf(name));
}
test.describe("camera live playback streams @medium", () => {
test("switching cameras updates the stream name field", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
const streamName = frigateApp.page.getByRole("textbox", {
name: "Stream name",
});
await expect(streamName).toHaveValue("front_door");
await expect(
frigateApp.page.getByRole("combobox", { name: "go2rtc stream" }),
).toContainText("front_door_main");
await selectCamera(frigateApp.page, "Backyard");
await expect(streamName).toHaveValue("backyard");
await expect(
frigateApp.page.getByRole("combobox", { name: "go2rtc stream" }),
).toContainText("backyard_main");
});
test("typing a new name enables Save without leaving the field", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
const save = frigateApp.page.getByRole("button", { name: "Save" });
await expect(save).toBeDisabled();
const streamName = await streamNameRow(frigateApp.page, "front_door");
await streamName.click();
await frigateApp.page.keyboard.press("End");
await frigateApp.page.keyboard.type("_hd");
// Still focused: the rename is committed per keystroke, not on blur.
await expect(save).toBeEnabled();
await expect(streamName).toBeFocused();
await expect(streamName).toHaveValue("front_door_hd");
});
test("typing through another stream's name keeps both streams", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, {
front: "front_door_main",
front_door: "backyard_main",
});
await frigateApp.goto(SETTINGS_URL);
const streamName = await streamNameRow(frigateApp.page, "front_door");
await streamName.click();
await frigateApp.page.keyboard.press("End");
// "front_door" passes through "front", which the other row already uses.
await frigateApp.page.keyboard.press("Backspace");
await frigateApp.page.keyboard.press("Backspace");
await frigateApp.page.keyboard.press("Backspace");
await frigateApp.page.keyboard.press("Backspace");
await frigateApp.page.keyboard.press("Backspace");
await expect(streamName).toHaveValue("front");
await frigateApp.page.keyboard.type("yard");
await streamName.blur();
expect(await streamNames(frigateApp.page)).toEqual(["frontyard", "front"]);
});
test("renaming a stream saves the new name for the selected camera", async ({
frigateApp,
}) => {
const capture = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
await selectCamera(frigateApp.page, "Backyard");
const streamName = frigateApp.page.getByRole("textbox", {
name: "Stream name",
});
await expect(streamName).toHaveValue("backyard");
await streamName.fill("Backyard HD");
// The rename is committed on blur, not on every keystroke.
await streamName.blur();
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
cameras: {
backyard: {
live: { streams: { "Backyard HD": "backyard_main" } },
},
},
},
});
});
});
@@ -5,6 +5,7 @@
import { canExpand } from "@rjsf/utils";
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LuPlus, LuChevronDown, LuChevronRight } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import {
@@ -12,7 +13,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
interface AddPropertyButtonProps {
/** Callback fired when the add button is clicked */
@@ -67,6 +68,72 @@ export function AddPropertyButton({
);
}
interface MapKeyInputProps {
/** DOM id used for label association */
id: string;
/** The committed key as it exists in the form data */
value: string;
/** Placeholder shown when the input is empty */
placeholder?: string;
/** Whether the input is disabled */
disabled?: boolean;
/** Additional class names */
className?: string;
/** Called with the edited key when it is safe to commit */
onCommit: (next: string) => void;
/** Whether another entry already uses this key, which defers the commit */
isKeyTaken?: (next: string) => boolean;
}
/**
* Text input for the key of a map entry (e.g. a live stream name).
*
* The edit is kept in local state so that the draft can be re-synced whenever
* the committed key changes underneath the input, which is what happens when
* the selected camera changes while the field stays mounted.
*
* Each keystroke is committed so the section is marked as modified right away,
* except while the typed key belongs to another entry: renaming onto an
* existing key merges the two entries, so a name typed through a neighbor's
* name would silently drop it. Those keystrokes stay local until the key is
* free again or the input is blurred.
*/
export function MapKeyInput({
id,
value,
placeholder,
disabled,
className,
onCommit,
isKeyTaken,
}: MapKeyInputProps) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
const handleChange = (next: string) => {
setDraft(next);
if (!isKeyTaken?.(next)) {
onCommit(next);
}
};
return (
<Input
id={id}
value={draft}
placeholder={placeholder}
disabled={disabled}
className={className}
onChange={(e) => handleChange(e.target.value)}
onBlur={() => onCommit(draft)}
/>
);
}
interface AdvancedCollapsibleProps {
/** Number of advanced fields */
count: number;
@@ -19,6 +19,7 @@ import {
import type { ConfigFormContext } from "@/types/configForm";
import get from "lodash/get";
import { isSubtreeModified } from "../utils";
import { MapKeyInput } from "../components";
type KnownPlatesData = Record<string, string[]>;
@@ -194,12 +195,16 @@ export function KnownPlatesField(props: FieldProps) {
className="space-y-2 rounded-md border p-3"
>
<div className="flex items-center gap-2">
<Input
<MapKeyInput
id={`${entryId}-key`}
defaultValue={key}
value={key}
placeholder={namePlaceholder}
disabled={disabled || readonly}
onBlur={(e) => handleRenameKey(key, e.target.value)}
onCommit={(next) => handleRenameKey(key, next)}
isKeyTaken={(next) =>
next !== key &&
Object.prototype.hasOwnProperty.call(data, next)
}
className="flex-1"
/>
<Button
@@ -3,7 +3,6 @@ import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Command,
@@ -20,6 +19,7 @@ import {
import { cn } from "@/lib/utils";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
import { LuPlus, LuTrash2 } from "react-icons/lu";
import { MapKeyInput } from "../components";
import type { ConfigFormContext } from "@/types/configForm";
import get from "lodash/get";
import { isSubtreeModified } from "../utils";
@@ -288,12 +288,16 @@ export function LiveStreamsField(props: FieldProps) {
>
<div className="col-span-12 space-y-2 md:col-span-5">
<Label htmlFor={`${entryId}-key`}>{streamNameLabel}</Label>
<Input
<MapKeyInput
id={`${entryId}-key`}
defaultValue={key}
value={key}
placeholder={streamNamePlaceholder}
disabled={disabled || readonly}
onBlur={(e) => handleRenameKey(key, e.target.value)}
onCommit={(next) => handleRenameKey(key, next)}
isKeyTaken={(next) =>
next !== key &&
Object.prototype.hasOwnProperty.call(data, next)
}
/>
</div>
<div className="col-span-10 space-y-2 md:col-span-6">