Miscellaneous fixes (#24402)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (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

* check for a valid frame before using its shape

With the camera offline, no preview frame, and `camera-error.jpg` missing, `latest_frame` read `frame.shape` before its `frame is None` check, so it raised `AttributeError` and answered 500 with a traceback instead of the intended "Unable to get valid frame". The check now runs first.

* fix the has_clip self-heal for events with no recordings

`vod_event` looked for a `(body, 404)` tuple, but `vod_ts` returns a `JSONResponse`, so the check never matched and an old event whose recordings are gone kept offering a clip that can't play. It now checks the response status code.

* return 403 for a snapshot or thumbnail on another camera

The broad `except Exception` handlers in `event_snapshot` and `event_thumbnail` caught the `HTTPException` from `require_camera_access`, so a restricted user asking for another camera's snapshot got a 404 instead of a 403, and for an object still being tracked the snapshot was rendered before the check ran. Both endpoints now look up the event and check access in their own block, the way the other endpoints do, so a denial propagates.

* find DST transitions to the second

`get_dst_transitions` probed the offset once every 24 hours from the start time and reported a change at the first probe after it, up to a day late, so events, review items and recordings near a transition were grouped into days with the old offset. A transition after the last daily probe wasn't found at all. The end of the range is probed too now, and a probe that sees the offset change bisects the interval to the second of the transition.

* don't run page shortcuts for keys a dialog already handled

Radix dismisses a dialog on Escape from a capture-phase keydown listener and calls `preventDefault()` without stopping propagation, so `useKeyboardListener` still ran the page's Escape shortcut: cancelling the delete dialog in the face library or a classification model also cleared the whole selection. Keys another shortcut hook handled still get through, since their listener order changes with every render.

* fix train image filtering for a class with a dash

The backend writes a class with a `-` as `_` in train file names, since it splits those names on `-`, while a dataset folder keeps the dash. Filtering the Train grid by `half-open` compared it with `half_open` and hid every attempt. Both sides are normalized the same way now.

* don't edit a chat message while a reply streams

The edit button stayed active while a reply streamed. `submitConversation` returns early while loading, but the message bubble still closed its editor, so the edit was silently lost. The edit button is hidden while a reply streams, and an editor that's already open keeps its draft with send disabled until the reply ends.

* fix restart failing under non-root

restart_frigate() called psutil.Process(1).terminate() to signal s6-svscan, but s6-svscan runs as root while frigate runs as uid 1000, so the call raised AccessDenied. That exception escaped every caller: the UI restart button dropped its websocket client, MQTT restart and Save & Restart just logged and did nothing, and the watchdog crashed its own monitoring thread on a dead detector. This catches AccessDenied and falls through to the existing SIGINT branch, which exits the process for s6 to restart it.

* show runtime overrides in the settings form

The settings form read a camera section's saved config value, but its dependent warnings (audio transcription requiring audio detection, snapshots requiring detect, etc.) read the live config instead. A runtime toggle from the live view, MQTT, or an active profile can turn a section off without touching yaml, and that override persists across restarts, so the Enable switch showed on while the warning said the feature wasn't enabled. This adds an "Overridden (Live)" badge to any field whose live value differs from what's saved, and swaps the affected warnings to runtime-specific wording when a runtime override is the actual cause instead of the config.

* fix mobile overflowing icons in system due to new health pane

* fix genai settings keeping a stale model and dropping roles after save

Switching a GenAI entry's provider left the previous provider's model selected, so saving wrote a model the new provider doesn't serve. llama.cpp can't find that model in `/v1/models`, so the backend reported every capability as false for the entry, and once the save refetched `genai/models` the roles widget stripped `transcribe` from the form on its own. The section showed unsaved changes right after saving, and saving again would have dropped the role. Switching provider now clears the model, and the roles widget only strips a role for a model or provider picked in the form, since the entry-level capability flags only describe the saved model. A selected role stays visible when the provider can't confirm it, so it can still be switched off. The llama.cpp model list also no longer repeats a model whose alias matches its id, which is what `--alias` produces.

* close onvif sessions on shutdown

`OnvifController.close()` only stopped its event loop, so the aiohttp sessions each `ONVIFCamera` holds and the `_poll_config_updates` task were left to be garbage collected during interpreter shutdown, when their warnings can no longer be logged. Every restart ended with a run of `Unclosed client session` and `Task was destroyed but it is pending!` logging errors, which only became visible once restart started exiting the process itself under non-root. `close()` now closes each camera's client and cancels the tasks on the loop before stopping it.

* fixes

* fixes
This commit is contained in:
Josh Hawkins
2026-09-18 07:33:23 -06:00
committed by GitHub
parent 0ca5cbbb63
commit 3d08bbe520
36 changed files with 1162 additions and 162 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+216
View File
@@ -0,0 +1,216 @@
/**
* Generative AI provider settings tests -- MEDIUM tier.
*
* A model name belongs to its provider, so switching provider clears the model
* field. The roles widget strips a role only for a model or provider picked in
* the form, never when capability data arrives for the saved entry, which would
* dirty the section on load and silently drop the role on the next save.
*/
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 ENTRY = "audio";
const SETTINGS_URL = "/settings?page=integrationGenerativeAi";
const UNSAVED = "You have unsaved changes";
const MODEL_PLACEHOLDER = "Select or enter a model…";
type Entry = {
provider: string;
model: string;
base_url?: string;
roles: string[];
};
type ProviderInfo = {
models: string[];
supports_transcription: boolean;
model_capabilities?: Record<string, { supports_transcription?: boolean }>;
};
async function installRoutes(page: Page, entry: Entry, info: ProviderInfo) {
const config = configFactory({ genai: { [ENTRY]: entry } });
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: { genai: { [ENTRY]: entry } } }),
);
await page.route("**/api/genai/models", (route) =>
route.fulfill({
json: {
[ENTRY]: {
roles: entry.roles,
supports_toggleable_thinking: false,
supports_embeddings: true,
model_capabilities: {},
...info,
},
},
}),
);
}
function roleSwitch(page: Page, role: string) {
return page.locator(`#root_${ENTRY}_roles-${role}`);
}
test.describe("genai provider settings @medium", () => {
test("a saved role the provider cannot confirm stays and is not dirty", async ({
frigateApp,
}) => {
// The server does not serve the saved model, so the backend reports every
// capability as false for the entry.
await installRoutes(
frigateApp.page,
{
provider: "llamacpp",
model: "stale-model",
base_url: "http://llama:8080",
roles: ["transcribe"],
},
{ models: ["qwen3-asr"], supports_transcription: false },
);
await frigateApp.goto(SETTINGS_URL);
await expect(roleSwitch(frigateApp.page, "transcribe")).toBeVisible();
await expect(roleSwitch(frigateApp.page, "transcribe")).toBeChecked();
// Give any stripping effect time to fire, then confirm the section stayed
// clean.
await frigateApp.page.waitForTimeout(1000);
await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden();
await expect(roleSwitch(frigateApp.page, "transcribe")).toBeChecked();
});
test("switching provider clears the model", async ({ frigateApp }) => {
await installRoutes(
frigateApp.page,
{
provider: "openai",
model: "gpt-4o",
roles: ["descriptions"],
},
{ models: ["gpt-4o"], supports_transcription: true },
);
await frigateApp.goto(SETTINGS_URL);
const model = frigateApp.page.locator(`#root_${ENTRY}_model`);
await expect(model).toHaveText("gpt-4o");
await frigateApp.page.locator(`#root_${ENTRY}_provider`).click();
await frigateApp.page.getByRole("option", { name: "llamacpp" }).click();
await expect(model).toHaveText(MODEL_PLACEHOLDER);
await expect(frigateApp.page.getByText(UNSAVED)).toBeVisible();
});
test("switching back to the saved provider drops the other provider's model", async ({
frigateApp,
}) => {
await installRoutes(
frigateApp.page,
{
provider: "openai",
model: "gpt-4o",
roles: ["descriptions"],
},
{ models: ["gpt-4o", "qwen3"], supports_transcription: true },
);
await frigateApp.goto(SETTINGS_URL);
const model = frigateApp.page.locator(`#root_${ENTRY}_model`);
const provider = frigateApp.page.locator(`#root_${ENTRY}_provider`);
await expect(model).toHaveText("gpt-4o");
await provider.click();
await frigateApp.page.getByRole("option", { name: "llamacpp" }).click();
await model.click();
await frigateApp.page.getByRole("option", { name: "qwen3" }).click();
await expect(model).toHaveText("qwen3");
// the model picked for llamacpp must not carry over to openai, and the
// saved one isn't filled back in since the endpoint may have changed
await provider.click();
await frigateApp.page
.getByRole("option", { name: "openai", exact: true })
.click();
await expect(model).toHaveText(MODEL_PLACEHOLDER);
});
test("undo after switching provider brings back the saved model", async ({
frigateApp,
}) => {
await installRoutes(
frigateApp.page,
{
provider: "openai",
model: "gpt-4o",
roles: ["descriptions"],
},
{ models: ["gpt-4o"], supports_transcription: true },
);
await frigateApp.goto(SETTINGS_URL);
const model = frigateApp.page.locator(`#root_${ENTRY}_model`);
await frigateApp.page.locator(`#root_${ENTRY}_provider`).click();
await frigateApp.page.getByRole("option", { name: "llamacpp" }).click();
await expect(model).toHaveText(MODEL_PLACEHOLDER);
await frigateApp.page.getByRole("button", { name: "Undo" }).click();
await expect(model).toHaveText("gpt-4o");
await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden();
});
test("picking a model that cannot transcribe strips the role", async ({
frigateApp,
}) => {
await installRoutes(
frigateApp.page,
{
provider: "llamacpp",
model: "qwen3-asr",
base_url: "http://llama:8080",
roles: ["transcribe"],
},
{
models: ["qwen3-asr", "text-only"],
supports_transcription: true,
model_capabilities: {
"qwen3-asr": { supports_transcription: true },
"text-only": { supports_transcription: false },
},
},
);
await frigateApp.goto(SETTINGS_URL);
await expect(roleSwitch(frigateApp.page, "transcribe")).toBeChecked();
await frigateApp.page.locator(`#root_${ENTRY}_model`).click();
await frigateApp.page.getByRole("option", { name: "text-only" }).click();
await expect(roleSwitch(frigateApp.page, "transcribe")).toBeHidden();
await expect(frigateApp.page.getByText(UNSAVED)).toBeVisible();
});
});
@@ -0,0 +1,117 @@
/**
* Runtime override tests -- MEDIUM tier.
*
* Live view, MQTT, and Home Assistant toggles change a camera's running config
* without touching yaml, and the change persists across restarts. The settings
* form edits yaml, so it keeps showing the saved value. Without a marker on the
* field and runtime-aware wording on dependent warnings, the two read as a
* contradiction: "audio detection is not enabled" next to a switch that is on.
*/
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 CAMERA = "front_door";
const TRANSCRIPTION_URL = `/settings?page=cameraAudioTranscription&camera=${CAMERA}`;
const AUDIO_URL = `/settings?page=cameraAudioEvents&camera=${CAMERA}`;
const CONFIG_DISABLED = /Audio detection is not enabled for this camera/;
const RUNTIME_DISABLED =
/Audio detection is enabled in your config, but it is currently turned off/;
type AudioState = { enabled: boolean; enabled_in_config: boolean };
async function installRoutes(page: Page, audio: AudioState) {
const config = configFactory({
cameras: {
[CAMERA]: {
audio,
// audio detection only runs on a stream carrying the audio role
ffmpeg: {
inputs: [
{
path: "rtsp://user:pass@host/front",
roles: ["record", "detect", "audio"],
},
],
},
audio_transcription: { enabled: true, enabled_in_config: true },
},
},
});
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({
json: { cameras: { [CAMERA]: { ffmpeg: { inputs: [] } } } },
}),
);
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 } });
});
}
test.describe("runtime overrides @medium", () => {
test("a runtime-only toggle gets its own wording and a field marker", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, {
enabled: false,
enabled_in_config: true,
});
await frigateApp.goto(TRANSCRIPTION_URL);
await expect(frigateApp.page.getByText(RUNTIME_DISABLED)).toBeVisible();
await expect(frigateApp.page.getByText(CONFIG_DISABLED)).toBeHidden();
// The audio section still shows the saved value, so the switch stays on and
// the marker carries the live state.
await frigateApp.goto(AUDIO_URL);
await expect(
frigateApp.page.getByRole("switch", { name: "Enable audio detection" }),
).toHaveAttribute("data-state", "checked");
// the boolean layout renders a mobile and a desktop label block, so only
// one of the two badges is on screen
await expect(
frigateApp.page.getByText("Overridden (Live)").filter({ visible: true }),
).toHaveCount(1);
});
test("a config-disabled section keeps the original wording", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, {
enabled: false,
enabled_in_config: false,
});
await frigateApp.goto(TRANSCRIPTION_URL);
await expect(frigateApp.page.getByText(CONFIG_DISABLED)).toBeVisible();
await expect(frigateApp.page.getByText(RUNTIME_DISABLED)).toBeHidden();
await frigateApp.goto(AUDIO_URL);
await expect(
frigateApp.page.getByRole("switch", { name: "Enable audio detection" }),
).toHaveAttribute("data-state", "unchecked");
await expect(
frigateApp.page.getByText("Overridden (Live)").filter({ visible: true }),
).toHaveCount(0);
});
});
+50 -1
View File
@@ -72,7 +72,15 @@ test.describe("System — tabs @medium", () => {
{ timeout: 15_000 },
);
await expect(frigateApp.page.getByText("0.15.0-test")).toBeVisible();
await expect(frigateApp.page.getByText(/Last refreshed/)).toBeVisible();
if (frigateApp.isMobile) {
// the "Last refreshed" label is dropped on mobile so the timestamp
// clears the centered logo
await expect(frigateApp.page.getByText(/Last refreshed/)).toHaveCount(0);
await expect(frigateApp.page.getByText(/Just now|ago/)).toBeVisible();
} else {
await expect(frigateApp.page.getByText(/Last refreshed/)).toBeVisible();
}
});
test("storage tab renders content after switching", async ({
@@ -234,4 +242,45 @@ test.describe("System — mobile @medium @mobile", () => {
{ timeout: 5_000 },
);
});
test("header controls leave the logo uncovered on a narrow phone", async ({
frigateApp,
}) => {
await frigateApp.goto("/system#general");
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await frigateApp.page.setViewportSize({ width: 320, height: 740 });
const logo = frigateApp.page.locator("svg.fill-current").first();
const tabs = frigateApp.page
.locator("[data-radix-scroll-area-viewport]")
.filter({ has: frigateApp.page.getByLabel("Select general") });
const refreshed = frigateApp.page.getByText(/Just now|ago/);
const logoBox = await logo.boundingBox();
const tabsBox = await tabs.boundingBox();
const refreshedBox = await refreshed.boundingBox();
expect(tabsBox!.x + tabsBox!.width).toBeLessThanOrEqual(logoBox!.x + 1);
expect(refreshedBox!.x).toBeGreaterThanOrEqual(logoBox!.x + logoBox!.width);
// the clipped tabs stay reachable by scrolling
const overflow = await tabs.evaluate((el) => ({
scroll: el.scrollWidth,
client: el.clientWidth,
}));
expect(overflow.scroll).toBeGreaterThan(overflow.client);
await tabs.evaluate((el) => {
el.scrollLeft = el.scrollWidth;
});
await frigateApp.page.getByLabel("Select cameras").click();
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
"data-state",
"on",
{ timeout: 5_000 },
);
});
});
+13 -3
View File
@@ -23,6 +23,9 @@
"overriddenGlobalHeading_one": "This camera overrides {{count}} field from the global config:",
"overriddenGlobalHeading_other": "This camera overrides {{count}} fields from the global config:",
"overriddenGlobalNoDeltas": "This camera overrides the global config, but no field values differ.",
"overriddenLive": "Overridden (Live)",
"overriddenLiveTooltip": "This camera is running with a different value than the one saved in your config, which is shown here. The live view, MQTT, or an active profile can change it while Frigate is running.",
"overriddenLiveValue": "Running value: {{value}}",
"overriddenBaseConfig": "Overridden (Base Config)",
"overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section",
"overriddenBaseConfigHeading_one": "The {{profile}} profile overrides {{count}} field from the base config:",
@@ -1954,20 +1957,25 @@
"configMessages": {
"review": {
"recordDisabled": "Recording is disabled, review items will not be generated.",
"recordRuntimeDisabled": "Recording is enabled in your config, but it is currently turned off for this camera, so review items will not be generated. Turn it back on from the camera's live view, or check whether an active profile is disabling it.",
"detectDisabled": "Object detection is disabled. Review items require detected objects to categorize alerts and detections.",
"detectRuntimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera. Review items require detected objects to categorize alerts and detections. Turn it back on from the camera's live view, or check whether an active profile is disabling it.",
"allNonAlertDetections": "All non-alert activity will be included as detections.",
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images."
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.",
"genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images."
},
"audio": {
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
},
"audioTranscription": {
"audioDetectionDisabled": "Audio detection is not enabled for this camera. Audio transcription requires audio detection to be active.",
"audioDetectionRuntimeDisabled": "Audio detection is enabled in your config, but it is currently turned off for this camera, so audio transcription will not run. Turn it back on from the camera's live view, or check whether an active profile is disabling it.",
"genaiProviderSelected": "A GenAI provider is selected, so the device and model size settings are ignored."
},
"detect": {
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.",
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function.",
"runtimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function until it is turned back on from the camera's live view, or until the active profile stops disabling it.",
"sceneWithoutModel": "No detection model is configured for this scene, so this camera falls back to the model with a scene of 'All cameras'. Add a model for this scene to give the camera its own.",
"resolutionShouldBeMultipleOfFour": "For best results, detect width and height should be multiples of 4. Other even values may produce visual artifacts or slight distortion in the detect stream.",
"aspectRatioMismatch": "The width and height you've entered don't match the aspect ratio of your current detect resolution. This may produce a stretched or distorted image.",
@@ -2002,10 +2010,12 @@
"noRecordSubRole": "No streams have the record_sub role defined. Sub stream recording will not function."
},
"birdseye": {
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye."
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye.",
"objectTrackingDetectRuntimeDisabled": "Birdseye includes tracked objects, but object detection is currently turned off for this camera even though your config enables it. The camera will not appear in Birdseye until it is turned back on from the camera's live view, or until the active profile stops disabling it."
},
"snapshots": {
"detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created."
"detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created.",
"detectRuntimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera, so snapshots will not be created. Turn it back on from the camera's live view, or check whether an active profile is disabling it."
},
"semanticSearch": {
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
+1 -1
View File
@@ -133,7 +133,7 @@ export function MessageBubble({
variant="select"
size="icon"
className="size-9 rounded-full"
disabled={!draftContent.trim()}
disabled={!draftContent.trim() || onEditSubmit == null}
onClick={handleEditSubmit}
aria-label={t("send")}
>
@@ -9,6 +9,11 @@ const audioTranscription: SectionConfigOverrides = {
health: (ctx) =>
ctx.fullCameraConfig?.audio_transcription?.enabled === true,
messageKey: "configMessages.audioTranscription.audioDetectionDisabled",
runtimeOverride: {
section: "audio",
messageKey:
"configMessages.audioTranscription.audioDetectionRuntimeDisabled",
},
severity: "warning",
condition: (ctx) => {
if (ctx.level === "camera" && ctx.fullCameraConfig) {
@@ -7,6 +7,11 @@ const birdseye: SectionConfigOverrides = {
{
key: "object-tracking-detect-disabled",
messageKey: "configMessages.birdseye.objectTrackingDetectDisabled",
runtimeOverride: {
section: "detect",
messageKey:
"configMessages.birdseye.objectTrackingDetectRuntimeDisabled",
},
severity: "info",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
@@ -63,6 +63,10 @@ const objects: SectionConfigOverrides = {
{
key: "detect-disabled",
messageKey: "configMessages.detect.disabled",
runtimeOverride: {
section: "detect",
messageKey: "configMessages.detect.runtimeDisabled",
},
severity: "info",
condition: (ctx) =>
ctx.level === "camera" &&
@@ -7,6 +7,10 @@ const review: SectionConfigOverrides = {
{
key: "record-disabled",
messageKey: "configMessages.review.recordDisabled",
runtimeOverride: {
section: "record",
messageKey: "configMessages.review.recordRuntimeDisabled",
},
severity: "warning",
condition: (ctx) => {
if (ctx.level === "camera" && ctx.fullCameraConfig) {
@@ -18,6 +22,10 @@ const review: SectionConfigOverrides = {
{
key: "detect-disabled",
messageKey: "configMessages.review.detectDisabled",
runtimeOverride: {
section: "detect",
messageKey: "configMessages.review.detectRuntimeDisabled",
},
severity: "info",
condition: (ctx) => {
if (ctx.level === "camera" && ctx.fullCameraConfig) {
@@ -64,6 +72,11 @@ const review: SectionConfigOverrides = {
field: "genai.image_source",
messageKey:
"configMessages.review.genaiImageSourceRecordingsRecordDisabled",
runtimeOverride: {
section: "record",
messageKey:
"configMessages.review.genaiImageSourceRecordingsRecordRuntimeDisabled",
},
severity: "warning",
position: "after",
condition: (ctx) => {
@@ -7,6 +7,10 @@ const snapshots: SectionConfigOverrides = {
{
key: "detect-disabled",
messageKey: "configMessages.snapshots.detectDisabled",
runtimeOverride: {
section: "detect",
messageKey: "configMessages.snapshots.detectRuntimeDisabled",
},
severity: "info",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
@@ -28,6 +28,17 @@ export type ConditionalMessage = {
values?: Record<string, unknown>;
/** Optional documentation path (e.g. "/configuration/object_detectors#model"). */
docLink?: string;
/**
* Alternate wording for when the section this message depends on is enabled
* in the config but turned off on the running camera. Without it the message
* reads as a contradiction, since the form shows the saved config value.
*/
runtimeOverride?: {
/** Camera section whose runtime state explains the message, e.g. "audio". */
section: string;
/** Translation key used in place of `messageKey`. */
messageKey: string;
};
/**
* Whether the Health tab evaluates this message against the saved config.
* Absent or false: form only. true: shown whenever condition() holds. A
@@ -1022,6 +1022,7 @@ export function ConfigSection({
formContext={{
level: effectiveLevel,
cameraName,
sectionPath,
globalValue,
cameraValue,
hasChanges,
@@ -19,6 +19,8 @@ import { LuExternalLink } from "react-icons/lu";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { requiresRestartForFieldPath } from "@/utils/configUtil";
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
import RuntimeOverrideIndicator from "@/components/indicators/RuntimeOverrideIndicator";
import { getRuntimeOverride } from "@/utils/runtimeOverrides";
import {
buildTranslationPath,
resolveConfigTranslation,
@@ -211,6 +213,18 @@ export function FieldTemplate(props: FieldTemplateProps) {
defaultRequiresRestart,
);
// The form shows saved config values, so flag any field the running camera
// currently disagrees with. Profile editing shows that profile's overrides
// instead, where the comparison does not apply.
const runtimeOverride =
isCameraLevel && !formContext?.isProfile
? getRuntimeOverride(
formContext?.fullCameraConfig,
formContext?.sectionPath,
pathSegments.join("."),
)
: undefined;
// Use schema title/description as primary source (from JSON Schema)
const schemaTitle = schema.title;
const schemaDescription = schema.description;
@@ -502,6 +516,12 @@ export function FieldTemplate(props: FieldTemplateProps) {
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
{runtimeOverride && (
<RuntimeOverrideIndicator
runtimeValue={runtimeOverride.runtime}
className="ml-2"
/>
)}
</Label>
);
};
@@ -519,6 +539,12 @@ export function FieldTemplate(props: FieldTemplateProps) {
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
{runtimeOverride && (
<RuntimeOverrideIndicator
runtimeValue={runtimeOverride.runtime}
className="ml-2"
/>
)}
</Label>
);
};
@@ -540,6 +566,12 @@ export function FieldTemplate(props: FieldTemplateProps) {
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
{runtimeOverride && (
<RuntimeOverrideIndicator
runtimeValue={runtimeOverride.runtime}
className="ml-2"
/>
)}
</Label>
);
};
@@ -59,20 +59,32 @@ export function GenAIModelWidget(props: WidgetProps) {
const formContext = registry?.formContext as ConfigFormContext | undefined;
// Build a fingerprint from the saved config's provider + base_url so the
// SWR key changes (and models are refetched) whenever those fields are saved.
const configFingerprint = useMemo(() => {
if (!providerKey) return "";
const savedEntry = useMemo<Record<string, unknown> | null>(() => {
if (!providerKey) return null;
const genai = (
formContext?.fullConfig as Record<string, unknown> | undefined
)?.genai;
if (!genai || typeof genai !== "object" || Array.isArray(genai)) return "";
if (!genai || typeof genai !== "object" || Array.isArray(genai)) {
return null;
}
const entry = (genai as Record<string, unknown>)[providerKey];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return "";
const e = entry as Record<string, unknown>;
return `${e.provider ?? ""}|${e.base_url ?? ""}`;
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return null;
}
return entry as Record<string, unknown>;
}, [providerKey, formContext?.fullConfig]);
const savedProvider =
typeof savedEntry?.provider === "string" ? savedEntry.provider : null;
const savedModel =
typeof savedEntry?.model === "string" ? savedEntry.model : "";
// Build a fingerprint from the saved config's provider + base_url so the
// SWR key changes (and models are refetched) whenever those fields are saved.
const configFingerprint = savedEntry
? `${savedEntry.provider ?? ""}|${savedEntry.base_url ?? ""}`
: "";
const { data: allModels, mutate: mutateModels } = useSWR<GenAIModelsResponse>(
"genai/models",
{
@@ -148,6 +160,18 @@ export function GenAIModelWidget(props: WidgetProps) {
typeof formEntry?.provider === "string" ? formEntry.provider : null;
const canProbe = Boolean(formProvider) && !probing;
// A model name belongs to its provider, so switching provider clears it,
// unless the form holds the saved provider and model together
const prevFormProvider = useRef(formProvider);
useEffect(() => {
const previous = prevFormProvider.current;
prevFormProvider.current = formProvider;
if (previous === formProvider) return;
if (formProvider === savedProvider && value === savedModel) return;
if (typeof value === "string" && value) onChange("");
}, [formProvider, savedProvider, savedModel, value, onChange]);
const probe = async () => {
if (!formEntry || !formProvider) return;
if (probeSuccessTimerRef.current) {
@@ -25,6 +25,22 @@ function normalizeValue(value: unknown): string[] {
return [];
}
function getString(value: unknown): string | undefined {
return typeof value === "string" && value ? value : undefined;
}
function getEntry(
entries: unknown,
providerKey: string | undefined,
): Record<string, unknown> | undefined {
if (!providerKey || !entries || typeof entries !== "object") return undefined;
const entry = (entries as Record<string, unknown>)[providerKey];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return undefined;
}
return entry as Record<string, unknown>;
}
function getProviderKey(widgetId: string): string | undefined {
const prefix = "root_";
const suffix = "_roles";
@@ -51,21 +67,26 @@ export function GenAIRolesWidget(props: WidgetProps) {
// The model currently chosen in the form, which is what the roles have to
// reflect. Reading the saved config instead would keep reporting the previous
// model's capabilities until a save and a refetch.
const selectedModel = useMemo(() => {
if (!providerKey) return undefined;
const formData = formContext?.formData as
Record<string, unknown> | undefined;
const entry = formData?.[providerKey];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return undefined;
}
const model = (entry as Record<string, unknown>).model;
return typeof model === "string" && model ? model : undefined;
}, [formContext?.formData, providerKey]);
const formEntry = useMemo(
() => getEntry(formContext?.formData, providerKey),
[formContext?.formData, providerKey],
);
const savedEntry = useMemo(
() => getEntry(formContext?.fullConfig?.genai, providerKey),
[formContext?.fullConfig?.genai, providerKey],
);
const selectedModel = getString(formEntry?.model);
// The entry-level capability flags describe the saved provider and model
// only, so they apply while the form still matches the saved entry.
const matchesSaved =
savedEntry !== undefined &&
getString(formEntry?.provider) === getString(savedEntry.provider) &&
selectedModel === getString(savedEntry.model);
// Capabilities the provider reported for that specific model. Absent when the
// provider cannot describe a model it has not loaded, in which case the
// entry-level flags (which describe the saved model) are the best available.
// provider cannot describe a model it has not loaded.
const modelCapabilities: GenAIModelCapabilities | undefined = useMemo(() => {
if (!providerKey || !selectedModel) return undefined;
return genaiInfo?.[providerKey]?.model_capabilities?.[selectedModel];
@@ -76,7 +97,7 @@ export function GenAIRolesWidget(props: WidgetProps) {
): boolean => {
const perModel = modelCapabilities?.[key];
if (perModel !== undefined) return perModel;
if (!providerKey) return true;
if (!providerKey || !matchesSaved) return true;
const info = genaiInfo?.[providerKey];
// assume supported when nothing is known, so a role is never hidden on
// missing information alone
@@ -95,9 +116,13 @@ export function GenAIRolesWidget(props: WidgetProps) {
return unsupported;
}, [embeddingsSupported, transcriptionSupported]);
// a selected role stays visible so it can still be switched off
const availableRoles = useMemo(
() => GENAI_ROLES.filter((role) => !unsupportedRoles.has(role)),
[unsupportedRoles],
() =>
GENAI_ROLES.filter(
(role) => !unsupportedRoles.has(role) || selectedRoles.includes(role),
),
[unsupportedRoles, selectedRoles],
);
const occupiedRoles = useMemo(() => {
@@ -123,13 +148,16 @@ export function GenAIRolesWidget(props: WidgetProps) {
return occupied;
}, [formContext?.formData, providerKey]);
// strip every unsupported role in a single onChange; two effects each
// rewriting the same value would race and lose one of the edits
// Strip every unsupported role in a single onChange; two effects each
// rewriting the same value would race and lose one of the edits. Only a
// model or provider picked in the form can rule a role out, so capability
// data arriving for the saved entry never edits the form on its own.
useEffect(() => {
if (matchesSaved) return;
if (!selectedRoles.some((role) => unsupportedRoles.has(role))) return;
onChange(selectedRoles.filter((role) => !unsupportedRoles.has(role)));
}, [unsupportedRoles, selectedRoles, onChange]);
}, [matchesSaved, unsupportedRoles, selectedRoles, onChange]);
const toggleRole = (role: string, enabled: boolean) => {
if (enabled) {
@@ -0,0 +1,55 @@
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent } from "../ui/tooltip";
import { TooltipTrigger } from "@radix-ui/react-tooltip";
type RuntimeOverrideIndicatorProps = {
/** The value the camera is running with, which differs from the saved config. */
runtimeValue: unknown;
className?: string;
};
/**
* Field-level companion to the section override badges. Marks a field the
* running camera has drifted from, so the saved value on screen never reads as
* the live one.
*/
export default function RuntimeOverrideIndicator({
runtimeValue,
className,
}: RuntimeOverrideIndicatorProps) {
const { t } = useTranslation(["views/settings", "common"]);
const displayValue =
typeof runtimeValue === "boolean"
? t(runtimeValue ? "button.on" : "button.off", { ns: "common" })
: Array.isArray(runtimeValue)
? runtimeValue.join(", ")
: String(runtimeValue);
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="secondary"
className={cn(
"cursor-default border-2 border-selected text-center align-middle text-xs font-normal text-primary-variant",
className,
)}
>
{t("button.overriddenLive", { ns: "views/settings" })}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-72">
<p>{t("button.overriddenLiveTooltip", { ns: "views/settings" })}</p>
<p className="mt-1">
{t("button.overriddenLiveValue", {
ns: "views/settings",
value: displayValue,
})}
</p>
</TooltipContent>
</Tooltip>
);
}
+7 -2
View File
@@ -4,6 +4,7 @@ import type {
FieldConditionalMessage,
MessageConditionContext,
} from "@/components/config-form/section-configs/types";
import { resolveMessageKey } from "@/utils/runtimeOverrides";
export function useConfigMessages(
messages: ConditionalMessage[] | undefined,
@@ -15,12 +16,16 @@ export function useConfigMessages(
} {
const activeMessages = useMemo(() => {
if (!messages || !context) return [];
return messages.filter((msg) => msg.condition(context));
return messages
.filter((msg) => msg.condition(context))
.map((msg) => ({ ...msg, messageKey: resolveMessageKey(msg, context) }));
}, [messages, context]);
const activeFieldMessages = useMemo(() => {
if (!fieldMessages || !context) return [];
return fieldMessages.filter((msg) => msg.condition(context));
return fieldMessages
.filter((msg) => msg.condition(context))
.map((msg) => ({ ...msg, messageKey: resolveMessageKey(msg, context) }));
}, [fieldMessages, context]);
return { activeMessages, activeFieldMessages };
+18 -1
View File
@@ -7,6 +7,16 @@ export type KeyModifiers = {
shift: boolean;
};
const handledByShortcut = new WeakSet<Event>();
// Radix dismisses a dialog or menu on Escape from a capture-phase listener and
// calls preventDefault() without stopping propagation, so a page shortcut would
// otherwise act on the same press. Keys another shortcut hook handled still get
// through, since their listener order changes with every render.
function handledElsewhere(event: KeyboardEvent): boolean {
return event.defaultPrevented && !handledByShortcut.has(event);
}
export default function useKeyboardListener(
keys: string[],
listener?: (key: string | null, modifiers: KeyModifiers) => boolean,
@@ -27,6 +37,10 @@ export default function useKeyboardListener(
return;
}
if (handledElsewhere(e)) {
return;
}
const modifiers = {
down: true,
repeat: e.repeat,
@@ -63,7 +77,10 @@ export default function useKeyboardListener(
}
} else if (keys.includes(e.key) && listener) {
const preventDefault = listener(e.key, modifiers);
if (preventDefault) e.preventDefault();
if (preventDefault) {
e.preventDefault();
handledByShortcut.add(e);
}
} else if (
listener &&
(e.key === "Shift" || e.key === "Control" || e.key === "Meta")
+1 -1
View File
@@ -362,7 +362,7 @@ export default function ChatPage() {
role="user"
content={msg.content}
messageIndex={i}
onEditSubmit={handleEditSubmit}
onEditSubmit={isLoading ? undefined : handleEditSubmit}
isComplete
showStats={showStats}
/>
+39 -29
View File
@@ -24,6 +24,8 @@ import HealthMetrics from "@/views/system/HealthMetrics";
import NoticeFilterButton from "@/components/health/NoticeFilterButton";
import { DEFAULT_NOTICE_FILTER, NoticeFilter } from "@/types/health";
import { useTranslation } from "react-i18next";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
const allMetrics = [
"health",
@@ -96,35 +98,43 @@ function System() {
{isMobile && (
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
)}
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SystemMetric) => {
if (value) {
setPageToggle(value);
}
}} // don't allow the severity to be unselected
>
{Object.values(metrics).map((item) => (
<ToggleGroupItem
key={item}
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
aria-label={`Select ${item}`}
<ScrollArea className={cn("whitespace-nowrap", isMobile && "w-[45%]")}>
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SystemMetric) => {
if (value) {
setPageToggle(value);
}
}} // don't allow the severity to be unselected
>
{item == "health" && <LuHeartPulse className="size-4" />}
{item == "general" && <LuActivity className="size-4" />}
{item == "enrichments" && <LuSearchCode className="size-4" />}
{item == "storage" && <LuHardDrive className="size-4" />}
{item == "cameras" && <FaVideo className="size-4" />}
{isDesktop && (
<div className="smart-capitalize">{t(item + ".title")}</div>
)}
</ToggleGroupItem>
))}
</ToggleGroup>
{Object.values(metrics).map((item) => (
<ToggleGroupItem
key={item}
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
aria-label={t("selectItem", {
ns: "common",
item: t(item + ".title"),
})}
>
{item == "health" && <LuHeartPulse className="size-4" />}
{item == "general" && <LuActivity className="size-4" />}
{item == "enrichments" && <LuSearchCode className="size-4" />}
{item == "storage" && <LuHardDrive className="size-4" />}
{item == "cameras" && <FaVideo className="size-4" />}
{isDesktop && (
<div className="smart-capitalize">{t(item + ".title")}</div>
)}
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
<div className="flex h-full items-center">
{pageToggle == "health" && (
@@ -135,7 +145,7 @@ function System() {
)}
{lastUpdated && pageToggle != "health" && (
<div className="h-full content-center text-sm text-muted-foreground">
{t("lastRefreshed")}
{isDesktop && t("lastRefreshed")}
<TimeAgo time={lastUpdated * 1000} dense />
</div>
)}
+2
View File
@@ -30,6 +30,8 @@ export type HiddenFieldEntry = string | ((ctx: HiddenFieldContext) => string[]);
export type ConfigFormContext = {
level?: "global" | "camera";
cameraName?: string;
/** Config section being edited, e.g. "audio" or "review". */
sectionPath?: string;
globalValue?: JsonValue;
cameraValue?: JsonValue;
overrides?: JsonValue;
+2 -1
View File
@@ -10,6 +10,7 @@ import type { FrigateConfig } from "@/types/frigateConfig";
import type { HealthProblem } from "@/types/health";
import { getSectionConfig } from "@/utils/configUtil";
import { activeCameras } from "@/utils/health";
import { resolveMessageKey } from "@/utils/runtimeOverrides";
function healthMessages(
section: string,
@@ -47,7 +48,7 @@ function toProblem(
severity: message.severity,
scope,
scopeIsCamera,
text: t(message.messageKey, {
text: t(resolveMessageKey(message, ctx), {
ns: "views/settings",
...(message.values ?? {}),
}),
+12 -5
View File
@@ -13,6 +13,7 @@ import set from "lodash/set";
import { isJsonObject } from "@/lib/utils";
import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const";
import { applySchemaDefaults } from "@/lib/config-schema";
import { applyConfiguredToggles } from "@/utils/runtimeOverrides";
import { normalizeConfigValue } from "@/hooks/use-config-override";
import {
modifySchemaForSection,
@@ -103,11 +104,13 @@ export const globalCameraDefaultSections = new Set([
// ---------------------------------------------------------------------------
/**
* Get the base (pre-profile) value for a camera section.
* Get the saved-config value for a camera section, which is what the settings
* form edits.
*
* When a profile is active the API populates `base_config` with original
* section values. This helper returns that value when available, falling
* back to the top-level (effective) value otherwise.
* Two things move the top-level (effective) value away from yaml. A profile
* merges its overrides into it, and the API then populates `base_config` with
* the originals. Runtime toggles from the live view, MQTT, or Home Assistant
* change it in place, and `applyConfiguredToggles` puts those fields back.
*/
export function getBaseCameraSectionValue(
config: FrigateConfig | undefined,
@@ -118,7 +121,11 @@ export function getBaseCameraSectionValue(
const cam = config.cameras?.[cameraName];
if (!cam) return undefined;
const base = cam.base_config?.[sectionPath];
return base !== undefined ? base : get(cam, sectionPath);
return applyConfiguredToggles(
cam,
sectionPath,
base !== undefined ? base : get(cam, sectionPath),
);
}
// mergeWith customizer that replaces arrays wholesale instead of merging them
+148
View File
@@ -0,0 +1,148 @@
import get from "lodash/get";
import isEqual from "lodash/isEqual";
import set from "lodash/set";
import cloneDeep from "lodash/cloneDeep";
import type { CameraConfig } from "@/types/frigateConfig";
import type {
ConditionalMessage,
MessageConditionContext,
} from "@/components/config-form/section-configs/types";
/**
* Camera fields the dispatcher can change at runtime from the live view, MQTT,
* or Home Assistant. Mirrors the camera command handlers in
* frigate/comms/dispatcher.py. Runtime changes persist across restarts and win
* over yaml until the field is saved again, so the settings form (which edits
* yaml) has to show the config value and flag the divergence.
*
* Paths are relative to the section.
*/
export const RUNTIME_TOGGLEABLE_FIELDS: Record<string, string[]> = {
audio: ["enabled"],
birdseye: ["enabled", "modes"],
detect: ["enabled"],
motion: ["enabled", "improve_contrast", "threshold", "contour_area"],
notifications: ["enabled"],
objects: ["genai.enabled"],
onvif: ["autotracking.enabled"],
record: ["enabled"],
review: ["alerts.enabled", "detections.enabled", "genai.enabled"],
snapshots: ["enabled"],
};
/**
* The value a field holds in yaml, or undefined when the backend exposes no
* config-side copy of it.
*
* Two sources carry it. `base_config` is the pre-profile snapshot, sent only
* while a profile is active. The `<field>_in_config` siblings are always sent,
* but only exist for the toggles the backend tracks that way (`detect`,
* `snapshots`, and `birdseye` have none).
*/
export function getConfiguredFieldValue(
cameraConfig: CameraConfig | undefined,
sectionPath: string,
fieldPath: string,
): unknown {
if (!cameraConfig) return undefined;
const inConfig = get(cameraConfig, `${sectionPath}.${fieldPath}_in_config`);
if (inConfig !== undefined && inConfig !== null) {
return inConfig;
}
const base = cameraConfig.base_config?.[sectionPath];
return base !== undefined ? get(base, fieldPath) : undefined;
}
export type RuntimeOverride = {
/** The value saved in yaml. */
configured: unknown;
/** The value the camera is running with right now. */
runtime: unknown;
};
/**
* Describes a field whose live value has drifted from the saved config, or
* undefined when the two agree or the config value can't be read.
*/
export function getRuntimeOverride(
cameraConfig: CameraConfig | undefined,
sectionPath: string | undefined,
fieldPath: string,
): RuntimeOverride | undefined {
if (!cameraConfig || !sectionPath) return undefined;
if (!RUNTIME_TOGGLEABLE_FIELDS[sectionPath]?.includes(fieldPath)) {
return undefined;
}
const configured = getConfiguredFieldValue(
cameraConfig,
sectionPath,
fieldPath,
);
if (configured === undefined) return undefined;
const runtime = get(cameraConfig, `${sectionPath}.${fieldPath}`);
if (runtime === undefined || isEqual(configured, runtime)) return undefined;
return { configured, runtime };
}
/**
* Whether a section is enabled in yaml but turned off on the running camera.
* This is the state that makes a dependent warning read as a contradiction,
* since the form shows the section switch on.
*/
export function isSectionRuntimeDisabled(
cameraConfig: CameraConfig | undefined,
sectionPath: string,
): boolean {
const override = getRuntimeOverride(cameraConfig, sectionPath, "enabled");
return override?.configured === true && override.runtime === false;
}
/**
* Overlays the saved config values onto a section so the form edits yaml
* rather than live state. Returns the section unchanged when nothing drifted.
*/
export function applyConfiguredToggles(
cameraConfig: CameraConfig | undefined,
sectionPath: string,
sectionValue: unknown,
): unknown {
const fields = RUNTIME_TOGGLEABLE_FIELDS[sectionPath];
if (!fields || !sectionValue || typeof sectionValue !== "object") {
return sectionValue;
}
let result = sectionValue;
for (const field of fields) {
const override = getRuntimeOverride(cameraConfig, sectionPath, field);
if (!override) continue;
if (result === sectionValue) {
result = cloneDeep(sectionValue);
}
set(result as object, field, override.configured);
}
return result;
}
/**
* Picks the wording for a message. A message that depends on another section
* being on switches to its runtime wording when the config has that section
* enabled but the running camera has it off.
*/
export function resolveMessageKey(
message: Pick<ConditionalMessage, "messageKey" | "runtimeOverride">,
ctx: MessageConditionContext | undefined,
): string {
const runtime = message.runtimeOverride;
if (!runtime || !ctx || ctx.level !== "camera") return message.messageKey;
return isSectionRuntimeDisabled(ctx.fullCameraConfig, runtime.section)
? runtime.messageKey
: message.messageKey;
}
@@ -899,6 +899,14 @@ type TrainGridProps = {
onRefresh: () => void;
onDelete: (ids: string[]) => void;
};
// the backend writes a class with a "-" as "_" in train file names, since it
// splits those names on "-", so a dataset class may still carry the dash
function matchesTrainClass(classes: string[], name: string): boolean {
const target = name.replaceAll("-", "_");
return classes.some((item) => item.replaceAll("-", "_") === target);
}
function TrainGrid({
model,
contentRef,
@@ -936,7 +944,10 @@ function TrainGrid({
return true;
}
if (trainFilter.classes && !trainFilter.classes.includes(data.name)) {
if (
trainFilter.classes &&
!matchesTrainClass(trainFilter.classes, data.name)
) {
return false;
}