Fixes (#23130)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled

* respect section hiddenFields when detecting config overrides

* change audio events to audio detection to match docs

* add field messages for object and review genai

* add more config messages

* more messages

* add guard to prevent race when adding camera dynamically

* fix duplicate websocket messages from zombie connection under react strict mode

detach ws event handlers before close() in WsProvider cleanup so a CONNECTING socket's deferred onclose can't schedule a reconnect after the next mount resets the unmounted guard, which was spawning a second live ws and duplicating every message

* fix double event publishes for stationary objects with attributes
This commit is contained in:
Josh Hawkins
2026-05-07 12:23:02 -06:00
committed by GitHub
parent d0f44de6bc
commit 4ff7ab96dc
17 changed files with 205 additions and 41 deletions
+8 -1
View File
@@ -56,7 +56,14 @@ export function WsProvider({ children }: { children: ReactNode }) {
if (reconnectTimer.current) {
clearTimeout(reconnectTimer.current);
}
wsRef.current?.close();
const ws = wsRef.current;
if (ws) {
ws.onopen = null;
ws.onmessage = null;
ws.onclose = null;
ws.onerror = null;
ws.close();
}
resetWsStore();
};
}, [wsUrl]);
@@ -3,6 +3,15 @@ import type { SectionConfigOverrides } from "./types";
const detect: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/camera_specific",
messages: [
{
key: "detect-disabled",
messageKey: "configMessages.detect.disabled",
severity: "info",
condition: (ctx) =>
ctx.level === "camera" && ctx.formData?.enabled === false,
},
],
fieldMessages: [
{
key: "fps-greater-than-five",
@@ -53,6 +53,16 @@ const faceRecognition: SectionConfigOverrides = {
"device",
],
restartRequired: ["enabled", "model_size", "device"],
fieldMessages: [
{
key: "model-size-large",
field: "model_size",
messageKey: "configMessages.faceRecognition.modelSizeLarge",
severity: "info",
position: "after",
condition: (ctx) => ctx.formData?.model_size === "large",
},
],
uiSchema: {
model_size: {
"ui:options": { size: "xs" },
@@ -65,6 +65,16 @@ const lpr: SectionConfigOverrides = {
"replace_rules",
],
restartRequired: ["model_size", "enhancement", "device"],
fieldMessages: [
{
key: "model-size-large",
field: "model_size",
messageKey: "configMessages.lpr.modelSizeLarge",
severity: "info",
position: "after",
condition: (ctx) => ctx.formData?.model_size === "large",
},
],
uiSchema: {
format: {
"ui:options": { size: "md" },
@@ -11,6 +11,32 @@ const hideAttributeFilters = (config: FrigateConfig): string[] =>
const objects: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/object_filters",
messages: [
{
key: "detect-disabled",
messageKey: "configMessages.detect.disabled",
severity: "info",
condition: (ctx) =>
ctx.level === "camera" &&
ctx.fullCameraConfig?.detect?.enabled === false,
},
],
fieldMessages: [
{
key: "genai-no-descriptions-provider",
field: "genai.enabled",
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
severity: "warning",
position: "before",
condition: (ctx) => {
const providers = ctx.fullConfig.genai;
if (!providers || Object.keys(providers).length === 0) return true;
return !Object.values(providers).some((agent) =>
agent.roles?.includes("descriptions"),
);
},
},
],
fieldDocs: {
"filters.min_area": "/configuration/object_filters#object-area",
"filters.max_area": "/configuration/object_filters#object-area",
@@ -41,6 +41,38 @@ const review: SectionConfigOverrides = {
return !Array.isArray(labels) || labels.length === 0;
},
},
{
key: "genai-no-descriptions-provider",
field: "genai.enabled",
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
severity: "warning",
position: "before",
condition: (ctx) => {
const providers = ctx.fullConfig.genai;
if (!providers || Object.keys(providers).length === 0) return true;
return !Object.values(providers).some((agent) =>
agent.roles?.includes("descriptions"),
);
},
},
{
key: "genai-image-source-recordings-record-disabled",
field: "genai.image_source",
messageKey:
"configMessages.review.genaiImageSourceRecordingsRecordDisabled",
severity: "warning",
position: "after",
condition: (ctx) => {
const genai = ctx.formData?.genai as
| Record<string, unknown>
| undefined;
if (genai?.image_source !== "recordings") return false;
if (ctx.level === "camera" && ctx.fullCameraConfig) {
return ctx.fullCameraConfig.record?.enabled === false;
}
return ctx.fullConfig.record?.enabled === false;
},
},
],
fieldDocs: {
"alerts.labels": "/configuration/review/#alerts-and-detections",
@@ -18,6 +18,18 @@ const semanticSearch: SectionConfigOverrides = {
advancedFields: ["reindex", "device"],
restartRequired: ["enabled", "model", "model_size", "device"],
hiddenFields: ["reindex"],
fieldMessages: [
{
key: "jinav2-small-model-size",
field: "model_size",
messageKey: "configMessages.semanticSearch.jinav2SmallModelSize",
severity: "warning",
position: "after",
condition: (ctx) =>
ctx.formData?.model === "jinav2" &&
ctx.formData?.model_size === "small",
},
],
uiSchema: {
model: {
"ui:widget": "semanticSearchModel",
+46 -6
View File
@@ -1,6 +1,7 @@
// Hook to detect when camera config overrides global defaults
import { useMemo } from "react";
import useSWR from "swr";
import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual";
import get from "lodash/get";
import set from "lodash/set";
@@ -8,7 +9,11 @@ import type { RJSFSchema } from "@rjsf/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import { JsonObject, JsonValue } from "@/types/configForm";
import { isJsonObject } from "@/lib/utils";
import { getBaseCameraSectionValue } from "@/utils/configUtil";
import {
getBaseCameraSectionValue,
getEffectiveHiddenFields,
unsetWithWildcard,
} from "@/utils/configUtil";
import { extractSectionSchema } from "@/hooks/use-config-schema";
import { applySchemaDefaults } from "@/lib/config-schema";
@@ -38,6 +43,21 @@ export function normalizeConfigValue(value: unknown): JsonValue {
return stripInternalFields(value as JsonValue);
}
/**
* Remove hidden-field paths from a value before comparison so fields the
* user can't change in the UI (e.g. motion masks, attribute filters) don't
* trigger override badges. Operates on a clone so the input is unchanged.
*/
function stripHiddenPaths(value: JsonValue, hiddenFields: string[]): JsonValue {
if (hiddenFields.length === 0 || !isJsonObject(value)) return value;
const cloned = cloneDeep(value) as JsonObject;
for (const path of hiddenFields) {
if (!path) continue;
unsetWithWildcard(cloned as Record<string, unknown>, path);
}
return cloned;
}
/**
* Collapse null and empty-object values for override comparisons so
* semantically equivalent shapes match. The schema may default `mask: None`
@@ -45,7 +65,7 @@ export function normalizeConfigValue(value: unknown): JsonValue {
* masks", so collapsing them here keeps the equality check honest. We
* keep this off the public `normalizeConfigValue` so save-flow code paths
* (which serialize form data) aren't affected.
*/
**/
function collapseEmpty(value: JsonValue): JsonValue {
if (Array.isArray(value)) {
return value.map(collapseEmpty);
@@ -202,8 +222,21 @@ export function useConfigOverride({
// Collapse empty/null values for comparison so semantically equivalent
// shapes (e.g. schema default `mask: null` vs runtime `mask: {}`) match.
const collapsedGlobal = collapseEmpty(normalizedGlobalValue);
const collapsedCamera = collapseEmpty(normalizedCameraValue);
// Also strip hidden-field paths (motion masks, attribute filters, etc.)
// so fields the user can't edit in the UI don't trigger override badges.
const hiddenFields = getEffectiveHiddenFields(
sectionPath,
"camera",
config,
);
const collapsedGlobal = stripHiddenPaths(
collapseEmpty(normalizedGlobalValue),
hiddenFields,
);
const collapsedCamera = stripHiddenPaths(
collapseEmpty(normalizedCameraValue),
hiddenFields,
);
const comparisonGlobal = compareFields
? pickFields(collapsedGlobal, compareFields)
@@ -328,8 +361,15 @@ export function useAllCameraOverrides(
getBaseCameraSectionValue(config, cameraName, key),
);
const collapsedGlobal = collapseEmpty(globalValue);
const collapsedCamera = collapseEmpty(cameraValue);
const hiddenFields = getEffectiveHiddenFields(key, "camera", config);
const collapsedGlobal = stripHiddenPaths(
collapseEmpty(globalValue),
hiddenFields,
);
const collapsedCamera = stripHiddenPaths(
collapseEmpty(cameraValue),
hiddenFields,
);
const comparisonGlobal = compareFields
? pickFields(collapsedGlobal, compareFields)
: collapsedGlobal;
+4 -1
View File
@@ -254,7 +254,10 @@ export function flattenOverrides(
// lodash `unset` treats `*` as a literal key. This helper expands wildcard
// segments so that e.g. `"filters.*.mask"` unsets `filters.<each key>.mask`.
function unsetWithWildcard(obj: Record<string, unknown>, path: string): void {
export function unsetWithWildcard(
obj: Record<string, unknown>,
path: string,
): void {
if (!path.includes("*")) {
unset(obj, path);
return;