Implement UI for managing multiple models (#24023)

* Implement hardware detection and UI management

* Cleanup Frigate+ detection

* Don't count model as changed

* Fixes for audio map error

* Add descriptions

* Enforce that all model must exist

* Fix hardware picking

* Docs fixes

* WebUI cleanup

* Cleanup handling of scenes

* UI refinement

* Cleanup recommended UI

* test fixews
This commit is contained in:
Nicolas Mowen
2026-08-22 11:40:42 -05:00
committed by Josh Hawkins
parent 79ea68caa2
commit 07ba2357e6
59 changed files with 2554 additions and 2587 deletions
@@ -110,6 +110,21 @@ const detect: SectionConfigOverrides = {
return Math.abs(newRatio - savedRatio) > 0.01;
},
},
{
key: "detect-scene-without-model",
field: "scene",
position: "after",
messageKey: "configMessages.detect.sceneWithoutModel",
severity: "warning",
docLink: "/configuration/object_detectors#running-more-than-one-model",
condition: (ctx) => {
const scene = ctx.formData?.scene as string | undefined;
if (!scene || scene === "all") return false;
const models = ctx.fullConfig?.models;
if (!models) return false;
return !models.some((model) => model.scene === scene);
},
},
{
key: "fps-greater-than-five",
field: "fps",
@@ -153,6 +168,7 @@ const detect: SectionConfigOverrides = {
],
fieldOrder: [
"enabled",
"scene",
"width",
"height",
"fps",
@@ -170,6 +186,11 @@ const detect: SectionConfigOverrides = {
tracking: ["min_initialized", "max_disappeared"],
},
uiSchema: {
scene: {
"ui:options": {
enumI18nPrefix: "detectionModels.scenes",
},
},
annotation_offset: {
"ui:options": {
signed: true,
@@ -186,6 +207,7 @@ const detect: SectionConfigOverrides = {
},
global: {
restartRequired: [
"scene",
"fps",
"width",
"height",
@@ -195,6 +217,7 @@ const detect: SectionConfigOverrides = {
},
camera: {
restartRequired: [
"scene",
"fps",
"width",
"height",
@@ -211,6 +234,7 @@ const detect: SectionConfigOverrides = {
hiddenFields: [
"enabled",
"enabled_in_config",
"scene",
"min_initialized",
"max_disappeared",
"annotation_offset",
@@ -1,28 +0,0 @@
import type { SectionConfigOverrides } from "./types";
const detectorHiddenFields = [
"*.model.labelmap",
"*.model.attributes_map",
"*.model",
"*.model_path",
];
const detectors: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/object_detectors",
fieldOrder: [],
advancedFields: [],
hiddenFields: detectorHiddenFields,
uiSchema: {
"ui:field": "DetectorHardwareField",
"ui:options": {
multiInstanceTypes: ["cpu", "onnx", "openvino", "edgetpu"],
typeOrder: ["onnx", "openvino", "edgetpu"],
hiddenByType: {},
hiddenFields: detectorHiddenFields,
},
},
},
};
export default detectors;
@@ -1,8 +1,24 @@
import type { SectionConfigOverrides } from "./types";
const model: SectionConfigOverrides = {
// scene and devices are rendered by ModelsField itself; the rest of each model
// is delegated back to the schema form
const modelFields = [
"path",
"labelmap_path",
"width",
"height",
"input_pixel_format",
"input_tensor",
"input_dtype",
"model_type",
];
const models: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/object_detectors#model",
sectionDocs: "/configuration/object_detectors",
// the default-model rule must be enforced as the list is edited, not
// only when the form is submitted
liveValidate: true,
fieldMessages: [
{
key: "model-optimized-for-320",
@@ -36,51 +52,46 @@ const model: SectionConfigOverrides = {
},
},
],
// every model field takes effect only when the detection processes restart
restartRequired: [
"path",
"labelmap_path",
"width",
"height",
"scene",
"devices",
...modelFields,
"labelmap",
"attributes_map",
"input_tensor",
"input_pixel_format",
"input_dtype",
"model_type",
],
fieldOrder: [
"path",
"labelmap_path",
"width",
"height",
"input_pixel_format",
"input_tensor",
"input_dtype",
"model_type",
],
advancedFields: [
"input_pixel_format",
"input_tensor",
"input_dtype",
"model_type",
],
].map((field) => `*.${field}`),
hiddenFields: [
"labelmap",
"attributes_map",
"colormap",
"all_attributes",
"non_logo_attributes",
"plus",
"*.labelmap",
"*.attributes_map",
"*.colormap",
"*.all_attributes",
"*.non_logo_attributes",
"*.plus",
],
uiSchema: {
path: {
"ui:options": { size: "md" },
},
labelmap_path: {
"ui:options": { size: "md" },
"ui:field": "ModelsField",
items: {
path: {
"ui:options": { size: "md" },
},
labelmap_path: {
"ui:options": { size: "md" },
},
input_pixel_format: {
"ui:options": { advanced: true, size: "xs" },
},
input_tensor: {
"ui:options": { advanced: true, size: "xs" },
},
input_dtype: {
"ui:options": { advanced: true, size: "xs" },
},
model_type: {
"ui:options": { advanced: true, size: "xs" },
},
},
},
},
};
export default model;
export default models;
@@ -2,6 +2,7 @@ import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next";
import { validateDetectDimensions } from "./detect";
import { validateFfmpegInputRoles } from "./ffmpeg";
import { validateDefaultModelExists } from "./models";
import { validateProxyRoleHeader } from "./proxy";
export type SectionValidation = (
@@ -28,6 +29,11 @@ export function getSectionValidation({
return (formData, errors) => validateFfmpegInputRoles(formData, errors, t);
}
if (sectionPath === "models") {
return (formData, errors) =>
validateDefaultModelExists(formData, errors, t);
}
if (sectionPath === "proxy" && level === "global") {
return (formData, errors) => validateProxyRoleHeader(formData, errors, t);
}
@@ -0,0 +1,31 @@
import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next";
import { isJsonObject } from "@/lib/utils";
const DEFAULT_SCENE = "all";
/**
* A camera that names no scene runs the model whose scene is `all`. Without one
* the backend rejects the config outright once a second model exists, and with
* a single model it silently runs every camera on whatever that model is. Both
* are surprising, so require the default to be present.
*/
export function validateDefaultModelExists(
formData: unknown,
errors: FormValidation,
t: TFunction,
): FormValidation {
if (!Array.isArray(formData) || formData.length === 0) {
return errors;
}
const hasDefault = formData.some(
(model) => isJsonObject(model) && model.scene === DEFAULT_SCENE,
);
if (!hasDefault) {
errors.addError?.(t("models.defaultRequired", { ns: "config/validation" }));
}
return errors;
}
@@ -23,7 +23,6 @@ import birdseye from "./section-configs/birdseye";
import classification from "./section-configs/classification";
import database from "./section-configs/database";
import detect from "./section-configs/detect";
import detectors from "./section-configs/detectors";
import environmentVars from "./section-configs/environment_vars";
import faceRecognition from "./section-configs/face_recognition";
import ffmpeg from "./section-configs/ffmpeg";
@@ -31,7 +30,7 @@ import genai from "./section-configs/genai";
import live from "./section-configs/live";
import logger from "./section-configs/logger";
import lpr from "./section-configs/lpr";
import model from "./section-configs/model";
import models from "./section-configs/models";
import motion from "./section-configs/motion";
import mqtt from "./section-configs/mqtt";
import networking from "./section-configs/networking";
@@ -76,8 +75,7 @@ export const sectionConfigs: Record<string, SectionConfigOverrides> = {
logger,
environment_vars: environmentVars,
telemetry,
detectors,
model,
models,
genai,
classification,
};
@@ -72,8 +72,7 @@ const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([
"environment_vars",
"telemetry",
"birdseye",
"detectors",
"model",
"models",
]);
type CameraEntryProps = {
@@ -16,7 +16,7 @@ import { getEffectiveAttributeLabels } from "@/utils/configUtil";
* Sections that require special handling at the global level.
* Add new section paths here as needed.
*/
const SPECIAL_CASE_SECTIONS = ["motion", "detectors", "genai"] as const;
const SPECIAL_CASE_SECTIONS = ["motion", "genai"] as const;
/**
* Check if a section requires special case handling.
@@ -36,8 +36,6 @@ export function isSpecialCaseSection(
/**
* Modify schema for sections that need defaults stripped or other modifications.
*
* - detectors: Strip the "default" field to prevent RJSF from merging the
* default {"cpu": {"type": "cpu"}} with stored detector keys.
* - genai: Inject a default provider value on the additionalProperties shape.
* - objects: Promote tracked attribute labels (face, license_plate, courier
* logos) from `filters.additionalProperties` to explicit
@@ -63,12 +61,6 @@ export function modifySchemaForSection(
return schema;
}
// detectors: Remove default to prevent merging with stored keys
if (sectionPath === "detectors" && "default" in schema) {
const { default: _, ...schemaWithoutDefault } = schema;
return schemaWithoutDefault;
}
if (sectionPath === "genai") {
const additional = schema.additionalProperties;
if (
@@ -270,8 +262,6 @@ function modifyObjectsSchema(
* - motion: Has anyOf schema with [null, MotionConfig]. When stored value is
* null, derive defaults from the non-null anyOf branch to avoid showing
* changes when navigating to the page.
* - detectors: Return empty object since the schema default would add unwanted
* keys to the stored configuration.
*/
export function getEffectiveDefaultsForSection(
sectionPath: string,
@@ -305,11 +295,6 @@ export function getEffectiveDefaultsForSection(
return applySchemaDefaults(motionSchema as RJSFSchema, {});
}
// detectors: Return empty object to avoid adding default keys
if (sectionPath === "detectors") {
return {};
}
return schemaDefaults;
}
@@ -424,27 +409,6 @@ export function sanitizeOverridesForSection(
return flattened;
};
// detectors: Strip readonly model fields that are generated on startup
// and should never be persisted back to the config file.
if (sectionPath === "detectors") {
const overridesObj = overrides as JsonObject;
const cleaned: JsonObject = {};
Object.entries(overridesObj).forEach(([key, value]) => {
if (!isJsonObject(value)) {
cleaned[key] = value;
return;
}
const cleanedValue = { ...value } as JsonObject;
delete cleanedValue.model;
delete cleanedValue.model_path;
cleaned[key] = cleanedValue;
});
return cleaned;
}
if (sectionPath === "logger") {
const overridesObj = overrides as JsonObject;
const logs = overridesObj.logs;
@@ -1,956 +0,0 @@
import type {
ErrorSchema,
FieldPathList,
FieldProps,
RJSFSchema,
UiSchema,
} from "@rjsf/utils";
import { toFieldPathId } from "@rjsf/utils";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
LuChevronDown,
LuChevronRight,
LuPlus,
LuTrash2,
} from "react-icons/lu";
import { applySchemaDefaults } from "@/lib/config-schema";
import { cn, isJsonObject, mergeUiSchema } from "@/lib/utils";
import { ConfigFormContext, JsonObject } from "@/types/configForm";
import { requiresRestartForFieldPath } from "@/utils/configUtil";
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { humanizeKey } from "../utils/i18n";
type DetectorHardwareFieldOptions = {
multiInstanceTypes?: string[];
hiddenByType?: Record<string, string[]>;
hiddenFields?: string[];
typeOrder?: string[];
};
type DetectorSchemaEntry = {
type: string;
schema: RJSFSchema;
};
const DEFAULT_MULTI_INSTANCE_TYPES = ["cpu", "onnx", "openvino"];
const EMPTY_HIDDEN_BY_TYPE: Record<string, string[]> = {};
const EMPTY_HIDDEN_FIELDS: string[] = [];
const EMPTY_TYPE_ORDER: string[] = [];
const isSchemaObject = (schema: unknown): schema is RJSFSchema =>
typeof schema === "object" && schema !== null;
const getUnionSchemas = (schema?: RJSFSchema): RJSFSchema[] => {
if (!schema) {
return [];
}
const schemaObj = schema as Record<string, unknown>;
const union = schemaObj.oneOf ?? schemaObj.anyOf;
if (Array.isArray(union)) {
return union.filter(isSchemaObject) as RJSFSchema[];
}
return [schema];
};
const getTypeValues = (schema: RJSFSchema): string[] => {
const schemaObj = schema as Record<string, unknown>;
const properties = schemaObj.properties as
| Record<string, unknown>
| undefined;
const typeSchema = properties?.type as Record<string, unknown> | undefined;
const values: string[] = [];
if (typeof typeSchema?.const === "string") {
values.push(typeSchema.const);
}
if (Array.isArray(typeSchema?.enum)) {
typeSchema.enum.forEach((value) => {
if (typeof value === "string") {
values.push(value);
}
});
}
return values;
};
const buildHiddenUiSchema = (paths: string[]): UiSchema => {
const result: UiSchema = {};
paths.forEach((path) => {
if (!path) {
return;
}
const segments = path.split(".").filter(Boolean);
if (segments.length === 0) {
return;
}
let cursor = result;
segments.forEach((segment, index) => {
if (index === segments.length - 1) {
cursor[segment] = {
...(cursor[segment] as UiSchema | undefined),
"ui:widget": "hidden",
} as UiSchema;
return;
}
const existing = (cursor[segment] as UiSchema | undefined) ?? {};
cursor[segment] = existing;
cursor = existing;
});
});
return result;
};
const getInstanceType = (value: unknown): string | undefined => {
if (!isJsonObject(value)) {
return undefined;
}
const typeValue = value.type;
return typeof typeValue === "string" && typeValue.length > 0
? typeValue
: undefined;
};
export function DetectorHardwareField(props: FieldProps) {
const {
schema,
uiSchema,
registry,
fieldPathId,
formData: rawFormData,
errorSchema,
disabled,
readonly,
hideError,
onBlur,
onFocus,
onChange,
} = props;
const formContext = registry.formContext as ConfigFormContext | undefined;
const configNamespace =
formContext?.i18nNamespace ??
(formContext?.level === "camera" ? "config/cameras" : "config/global");
const { t: fallbackT } = useTranslation(["common", configNamespace]);
const t = formContext?.t ?? fallbackT;
const sectionPrefix = formContext?.sectionI18nPrefix ?? "detectors";
const restartRequired = formContext?.restartRequired;
const defaultRequiresRestart = formContext?.requiresRestart ?? true;
const options =
(uiSchema?.["ui:options"] as DetectorHardwareFieldOptions | undefined) ??
{};
const multiInstanceTypes =
options.multiInstanceTypes ?? DEFAULT_MULTI_INSTANCE_TYPES;
const hiddenByType = options.hiddenByType ?? EMPTY_HIDDEN_BY_TYPE;
const hiddenFields = options.hiddenFields ?? EMPTY_HIDDEN_FIELDS;
const typeOrder = options.typeOrder ?? EMPTY_TYPE_ORDER;
const multiInstanceSet = useMemo(
() => new Set(multiInstanceTypes),
[multiInstanceTypes],
);
const globalHiddenFields = useMemo(
() =>
hiddenFields
.map((path) => (path.startsWith("*.") ? path.slice(2) : path))
.filter((path) => path.length > 0),
[hiddenFields],
);
const detectorConfigSchema = useMemo(() => {
const additional = (schema as RJSFSchema | undefined)?.additionalProperties;
if (isSchemaObject(additional)) {
return additional as RJSFSchema;
}
const rootSchema = registry.rootSchema as Record<string, unknown>;
const defs =
(rootSchema?.$defs as Record<string, unknown> | undefined) ??
(rootSchema?.definitions as Record<string, unknown> | undefined);
const fallback = defs?.DetectorConfig;
return isSchemaObject(fallback) ? (fallback as RJSFSchema) : undefined;
}, [schema, registry.rootSchema]);
const detectorSchemas = useMemo<DetectorSchemaEntry[]>(() => {
const entries: DetectorSchemaEntry[] = [];
getUnionSchemas(detectorConfigSchema).forEach((schema) => {
const types = getTypeValues(schema);
types.forEach((type) => {
entries.push({ type, schema });
});
});
return entries;
}, [detectorConfigSchema]);
const detectorSchemaByType = useMemo(() => {
const map = new Map<string, RJSFSchema>();
detectorSchemas.forEach(({ type, schema }) => {
if (!map.has(type)) {
map.set(type, schema);
}
});
return map;
}, [detectorSchemas]);
const availableTypes = useMemo(
() => detectorSchemas.map((entry) => entry.type),
[detectorSchemas],
);
const orderedTypes = useMemo(() => {
if (!typeOrder.length) {
return availableTypes;
}
const availableSet = new Set(availableTypes);
const ordered = typeOrder.filter((type) => availableSet.has(type));
const orderedSet = new Set(ordered);
const remaining = availableTypes.filter((type) => !orderedSet.has(type));
return [...ordered, ...remaining];
}, [availableTypes, typeOrder]);
const formData = isJsonObject(rawFormData) ? rawFormData : {};
const detectors = formData as JsonObject;
const [addType, setAddType] = useState<string | undefined>(orderedTypes[0]);
const [addError, setAddError] = useState<string | undefined>();
const [renameDrafts, setRenameDrafts] = useState<Record<string, string>>({});
const [renameErrors, setRenameErrors] = useState<Record<string, string>>({});
const [typeErrors, setTypeErrors] = useState<Record<string, string>>({});
const [openKeys, setOpenKeys] = useState<Set<string>>(
() => new Set(Object.keys(detectors)),
);
useEffect(() => {
if (!orderedTypes.length) {
setAddType(undefined);
return;
}
if (!addType || !orderedTypes.includes(addType)) {
setAddType(orderedTypes[0]);
}
}, [orderedTypes, addType]);
useEffect(() => {
setOpenKeys((prev) => {
const next = new Set<string>();
Object.keys(detectors).forEach((key) => {
if (prev.has(key)) {
next.add(key);
}
});
return next;
});
setRenameDrafts((prev) => {
const next: Record<string, string> = {};
Object.keys(detectors).forEach((key) => {
if (prev[key] !== undefined) {
next[key] = prev[key];
}
});
return next;
});
setRenameErrors((prev) => {
const next: Record<string, string> = {};
Object.keys(detectors).forEach((key) => {
if (prev[key] !== undefined) {
next[key] = prev[key];
}
});
return next;
});
setTypeErrors((prev) => {
const next: Record<string, string> = {};
Object.keys(detectors).forEach((key) => {
if (prev[key] !== undefined) {
next[key] = prev[key];
}
});
return next;
});
}, [detectors]);
const updateDetectors = useCallback(
(nextDetectors: JsonObject, path?: FieldPathList) => {
onChange(nextDetectors as unknown, path ?? fieldPathId.path);
},
[fieldPathId.path, onChange],
);
const getTypeLabel = useCallback(
(type: string) =>
t(`${sectionPrefix}.${type}.label`, {
ns: configNamespace,
defaultValue: humanizeKey(type),
}),
[t, sectionPrefix, configNamespace],
);
const getTypeDescription = useCallback(
(type: string) =>
t(`${sectionPrefix}.${type}.description`, {
ns: configNamespace,
defaultValue: "",
}),
[t, sectionPrefix, configNamespace],
);
const shouldShowRestartForPath = useCallback(
(path: Array<string | number>) =>
requiresRestartForFieldPath(
path,
restartRequired,
defaultRequiresRestart,
),
[defaultRequiresRestart, restartRequired],
);
const renderRestartIcon = (isRequired: boolean) => {
if (!isRequired) {
return null;
}
return <RestartRequiredIndicator className="ml-2" />;
};
const isSingleInstanceType = useCallback(
(type: string) => !multiInstanceSet.has(type),
[multiInstanceSet],
);
const getDetectorDefaults = useCallback(
(type: string) => {
const schema = detectorSchemaByType.get(type);
if (!schema) {
return { type };
}
const base = { type } as Record<string, unknown>;
const withDefaults = applySchemaDefaults(schema, base);
return { ...withDefaults, type } as Record<string, unknown>;
},
[detectorSchemaByType],
);
const resolveDuplicateType = useCallback(
(targetType: string, excludeKey?: string) => {
return Object.entries(detectors).some(([key, value]) => {
if (excludeKey && key === excludeKey) {
return false;
}
return getInstanceType(value) === targetType;
});
},
[detectors],
);
const getExistingType = useCallback(
(excludeKey?: string): string | undefined => {
for (const [key, value] of Object.entries(detectors)) {
if (excludeKey && key === excludeKey) continue;
const type = getInstanceType(value);
if (type) return type;
}
return undefined;
},
[detectors],
);
const handleAdd = useCallback(() => {
if (!addType) {
setAddError(
t("selectItem", {
ns: "common",
defaultValue: "Select {{item}}",
item: t("detectors.type.label", {
ns: configNamespace,
defaultValue: "Type",
}),
}),
);
return;
}
if (isSingleInstanceType(addType) && resolveDuplicateType(addType)) {
setAddError(
t("configForm.detectors.singleType", {
ns: "views/settings",
defaultValue: "Only one {{type}} detector is allowed.",
type: getTypeLabel(addType),
}),
);
return;
}
const existingType = getExistingType();
if (existingType && existingType !== addType) {
const canAddExisting =
multiInstanceSet.has(existingType) ||
!resolveDuplicateType(existingType);
setAddError(
canAddExisting
? t("configMessages.detectors.mixedTypesSuggestion", {
ns: "views/settings",
defaultValue:
"All detectors must use the same type. Remove existing detectors or select {{type}}.",
type: getTypeLabel(existingType),
})
: t("configMessages.detectors.mixedTypes", {
ns: "views/settings",
defaultValue:
"All detectors must use the same type. Remove existing detectors to use a different type.",
}),
);
return;
}
const baseKey = addType;
let nextKey = baseKey;
let index = 2;
while (Object.prototype.hasOwnProperty.call(detectors, nextKey)) {
nextKey = `${baseKey}${index}`;
index += 1;
}
const nextDetectors = {
...detectors,
[nextKey]: getDetectorDefaults(addType),
} as JsonObject;
setAddError(undefined);
setOpenKeys((prev) => {
const next = new Set(prev);
next.add(nextKey);
return next;
});
updateDetectors(nextDetectors);
}, [
addType,
t,
configNamespace,
detectors,
getDetectorDefaults,
getExistingType,
getTypeLabel,
isSingleInstanceType,
multiInstanceSet,
resolveDuplicateType,
updateDetectors,
]);
const handleRemove = useCallback(
(key: string) => {
const { [key]: _, ...rest } = detectors;
updateDetectors(rest as JsonObject);
setOpenKeys((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
},
[detectors, updateDetectors],
);
const commitRename = useCallback(
(key: string, nextKey: string) => {
const trimmed = nextKey.trim();
if (!trimmed) {
setRenameErrors((prev) => ({
...prev,
[key]: t("configForm.detectors.keyRequired", {
ns: "views/settings",
defaultValue: "Detector name is required.",
}),
}));
return;
}
if (trimmed !== key && detectors[trimmed] !== undefined) {
setRenameErrors((prev) => ({
...prev,
[key]: t("configForm.detectors.keyDuplicate", {
ns: "views/settings",
defaultValue: "Detector name already exists.",
}),
}));
return;
}
setRenameErrors((prev) => {
const { [key]: _, ...rest } = prev;
return rest;
});
setRenameDrafts((prev) => {
const { [key]: _, ...rest } = prev;
return rest;
});
if (trimmed === key) {
return;
}
const { [key]: value, ...rest } = detectors;
const nextDetectors = { ...rest, [trimmed]: value } as JsonObject;
setOpenKeys((prev) => {
const next = new Set(prev);
if (next.delete(key)) {
next.add(trimmed);
}
return next;
});
updateDetectors(nextDetectors);
},
[detectors, t, updateDetectors],
);
const handleTypeChange = useCallback(
(key: string, nextType: string) => {
const currentType = getInstanceType(detectors[key]);
if (!nextType || nextType === currentType) {
return;
}
if (
isSingleInstanceType(nextType) &&
resolveDuplicateType(nextType, key)
) {
setTypeErrors((prev) => ({
...prev,
[key]: t("configForm.detectors.singleType", {
ns: "views/settings",
defaultValue: "Only one {{type}} detector is allowed.",
type: getTypeLabel(nextType),
}),
}));
return;
}
const existingType = getExistingType(key);
if (existingType && existingType !== nextType) {
const canAddExisting =
multiInstanceSet.has(existingType) ||
!resolveDuplicateType(existingType, key);
setTypeErrors((prev) => ({
...prev,
[key]: canAddExisting
? t("configMessages.detectors.mixedTypesSuggestion", {
ns: "views/settings",
defaultValue:
"All detectors must use the same type. Remove existing detectors or select {{type}}.",
type: getTypeLabel(existingType),
})
: t("configMessages.detectors.mixedTypes", {
ns: "views/settings",
defaultValue:
"All detectors must use the same type. Remove existing detectors to use a different type.",
}),
}));
return;
}
setTypeErrors((prev) => {
const { [key]: _, ...rest } = prev;
return rest;
});
const nextDetectors = {
...detectors,
[key]: getDetectorDefaults(nextType),
} as JsonObject;
updateDetectors(nextDetectors);
},
[
detectors,
getDetectorDefaults,
getExistingType,
getTypeLabel,
isSingleInstanceType,
multiInstanceSet,
resolveDuplicateType,
t,
updateDetectors,
],
);
const getInstanceUiSchema = useCallback(
(type: string) => {
const baseUiSchema =
(uiSchema?.additionalProperties as UiSchema | undefined) ?? {};
const globalHidden = buildHiddenUiSchema(globalHiddenFields);
const hiddenOverrides = buildHiddenUiSchema(hiddenByType[type] ?? []);
const typeHidden = { type: { "ui:widget": "hidden" } } as UiSchema;
const nestedOverrides = {
"ui:options": {
disableNestedCard: true,
addButtonText: t("configForm.detectors.addCustomKey", {
ns: "views/settings",
defaultValue: "Add custom key",
}),
},
} as UiSchema;
const withGlobalHidden = mergeUiSchema(baseUiSchema, globalHidden);
const withTypeHidden = mergeUiSchema(withGlobalHidden, hiddenOverrides);
const withTypeHiddenAndOptions = mergeUiSchema(
withTypeHidden,
typeHidden,
);
return mergeUiSchema(withTypeHiddenAndOptions, nestedOverrides);
},
[globalHiddenFields, hiddenByType, t, uiSchema?.additionalProperties],
);
const renderInstanceForm = useCallback(
(key: string, value: unknown) => {
const SchemaField = registry.fields.SchemaField;
const type = getInstanceType(value);
const schema = type ? detectorSchemaByType.get(type) : undefined;
if (!SchemaField || !schema || !type) {
return null;
}
const instanceUiSchema = getInstanceUiSchema(type);
const instanceFieldPathId = toFieldPathId(
key,
registry.globalFormOptions,
fieldPathId.path,
);
const instanceErrorSchema = (
errorSchema as Record<string, ErrorSchema> | undefined
)?.[key];
const handleInstanceChange = (
nextValue: unknown,
path: FieldPathList,
errors?: ErrorSchema,
id?: string,
) => {
onChange(nextValue, path, errors, id);
};
return (
<SchemaField
name={key}
schema={schema}
uiSchema={instanceUiSchema}
fieldPathId={instanceFieldPathId}
formData={value}
errorSchema={instanceErrorSchema}
onChange={handleInstanceChange}
onBlur={onBlur}
onFocus={onFocus}
registry={registry}
disabled={disabled}
readonly={readonly}
hideError={hideError}
/>
);
},
[
detectorSchemaByType,
getInstanceUiSchema,
disabled,
errorSchema,
fieldPathId,
hideError,
onChange,
onBlur,
onFocus,
readonly,
registry,
],
);
if (!availableTypes.length) {
return (
<p className="text-sm text-muted-foreground">
{t("configForm.detectors.noSchema", {
ns: "views/settings",
defaultValue: "No detector schemas are available.",
})}
</p>
);
}
const detectorEntries = Object.entries(detectors);
const isDisabled = Boolean(disabled || readonly);
return (
<div className="space-y-4">
{detectorEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("configForm.detectors.none", {
ns: "views/settings",
defaultValue: "No detector instances configured.",
})}
</p>
) : (
<div className="space-y-3">
{detectorEntries.map(([key, value]) => {
const type = getInstanceType(value) ?? "";
const typeLabel = type ? getTypeLabel(type) : key;
const typeDescription = type ? getTypeDescription(type) : "";
const isOpen = openKeys.has(key);
const renameDraft = renameDrafts[key] ?? key;
const detectorPath = [...fieldPathId.path, key];
const detectorTypePath = [...detectorPath, "type"];
const detectorTypeRequiresRestart =
shouldShowRestartForPath(detectorTypePath);
return (
<div key={key} className="rounded-lg border bg-card">
<Collapsible
open={isOpen}
onOpenChange={(open) => {
setOpenKeys((prev) => {
const next = new Set(prev);
if (open) {
next.add(key);
} else {
next.delete(key);
}
return next;
});
}}
>
<div className="flex items-start justify-between gap-4 p-4">
<div className="flex items-start gap-3">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className="mt-0.5"
>
{isOpen ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
<div>
<div className="flex items-center text-sm font-medium">
{typeLabel}
{renderRestartIcon(detectorTypeRequiresRestart)}
<span className="ml-2 text-xs text-muted-foreground">
{key}
</span>
</div>
{typeDescription && (
<div className="text-xs text-muted-foreground">
{typeDescription}
</div>
)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => handleRemove(key)}
disabled={isDisabled}
>
<LuTrash2 className="h-4 w-4" />
</Button>
</div>
<CollapsibleContent>
<div className="space-y-4 border-t p-4">
<div className="grid gap-4 md:grid-cols-4">
<div className="space-y-2">
<Label className="flex items-center">
{t("label.ID", {
ns: "common",
defaultValue: "ID",
})}
</Label>
<Input
value={renameDraft}
disabled={isDisabled}
onChange={(event) => {
setRenameDrafts((prev) => ({
...prev,
[key]: event.target.value,
}));
}}
onBlur={(event) =>
commitRename(key, event.target.value)
}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitRename(key, renameDraft);
}
}}
/>
<p className="text-xs text-muted-foreground">
{t("field.internalID", {
ns: "common",
defaultValue:
"The Internal ID Frigate uses in the configuration and database",
})}
</p>
{renameErrors[key] && (
<p className="text-xs text-danger">
{renameErrors[key]}
</p>
)}
</div>
<div className="col-span-3 space-y-2">
<Label className="flex items-center">
{t("detectors.type.label", {
ns: configNamespace,
defaultValue: "Type",
})}
</Label>
<Select
value={type}
onValueChange={(value) =>
handleTypeChange(key, value)
}
disabled={isDisabled}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("selectItem", {
ns: "common",
defaultValue: "Select {{item}}",
item: t("detectors.type.label", {
ns: configNamespace,
defaultValue: "Type",
}),
})}
/>
</SelectTrigger>
<SelectContent>
{orderedTypes.map((option) => (
<SelectItem key={option} value={option}>
{getTypeLabel(option)}
</SelectItem>
))}
</SelectContent>
</Select>
{typeErrors[key] && (
<p className="text-xs text-danger">
{typeErrors[key]}
</p>
)}
</div>
</div>
<div className={cn(readonly && "opacity-90")}>
{renderInstanceForm(key, value)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
})}
</div>
)}
<div className="flex justify-start pt-5">
<div className="w-full max-w-lg rounded-lg border bg-card p-4">
<div className="text-sm font-medium text-muted-foreground">
{t("configForm.detectors.add", {
ns: "views/settings",
defaultValue: "Add detector",
})}
</div>
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-end">
<div className="flex-1 space-y-2">
<Label>
{t("detectors.type.label", {
ns: configNamespace,
defaultValue: "Type",
})}
</Label>
<Select
value={addType ?? ""}
onValueChange={(value) => {
setAddError(undefined);
setAddType(value);
}}
disabled={isDisabled}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("selectItem", {
ns: "common",
defaultValue: "Select {{item}}",
item: t("detectors.type.label", {
ns: configNamespace,
defaultValue: "Type",
}),
})}
/>
</SelectTrigger>
<SelectContent>
{orderedTypes.map((type) => (
<SelectItem key={type} value={type}>
{getTypeLabel(type)}
</SelectItem>
))}
</SelectContent>
</Select>
{addError && <p className="text-xs text-danger">{addError}</p>}
</div>
<div>
<Button
type="button"
variant="outline"
onClick={handleAdd}
disabled={isDisabled}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("button.add", {
ns: "common",
defaultValue: "Add",
})}
</Button>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,266 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DetectionHardware } from "@/types/hardware";
import {
hardwareForDevices,
MAX_DETECTORS,
recommendedDetectorCount,
} from "@/utils/detectionHardware";
type HardwarePickerProps = {
// scopes the unit checkbox ids, since several models can list the same unit
idPrefix: string;
devices: string[];
// device strings already taken by another model, mapped to that model's scene
claimedElsewhere: Record<string, string>;
cameraCount: number;
disabled?: boolean;
onChange: (devices: string[]) => void;
};
export function HardwarePicker({
idPrefix,
devices,
claimedElsewhere,
cameraCount,
disabled,
onChange,
}: HardwarePickerProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { data: hardware, isLoading } =
useSWR<DetectionHardware[]>("hardware/probe");
const selected = useMemo(
() => hardwareForDevices(hardware ?? [], devices),
[hardware, devices],
);
const recommended = useMemo(
() => recommendedDetectorCount(cameraCount),
[cameraCount],
);
// the units a model is assigned to, in the order the probe reports them. A
// shareable unit repeats in `devices` once per inference process, so the
// distinct entries are what is selected.
const selectedUnits = useMemo(() => {
if (!selected) {
return [];
}
return selected.units
.map((unit) => unit.device)
.filter((device) => devices.includes(device));
}, [selected, devices]);
/** Spread `count` detectors round robin over the selected units. */
const buildDevices = useCallback((units: string[], count: number) => {
if (units.length === 0) {
return [];
}
return Array.from(
{ length: Math.max(count, units.length) },
(_, index) => units[index % units.length],
);
}, []);
const handleHardwareChange = useCallback(
(key: string) => {
const entry = hardware?.find((candidate) => candidate.key === key);
if (!entry) {
return;
}
// start with the first unit no other model has taken
const free = entry.units.find((unit) => !claimedElsewhere[unit.device]);
if (!free) {
onChange([]);
return;
}
onChange(
entry.unlimited
? buildDevices([free.device], recommended)
: [free.device],
);
},
[hardware, claimedElsewhere, recommended, buildDevices, onChange],
);
const handleUnitToggle = useCallback(
(device: string, checked: boolean) => {
if (!selected) {
return;
}
const units = selected.units
.map((unit) => unit.device)
.filter((candidate) =>
candidate === device ? checked : selectedUnits.includes(candidate),
);
if (!selected.unlimited) {
onChange(units);
return;
}
// keep the detector count while the set of units changes
onChange(buildDevices(units, devices.length));
},
[selected, selectedUnits, devices.length, buildDevices, onChange],
);
const handleCountChange = useCallback(
(value: string) => {
onChange(buildDevices(selectedUnits, Number(value)));
},
[selectedUnits, buildDevices, onChange],
);
if (isLoading) {
return (
<p className="text-sm text-muted-foreground">
{t("detectionModels.hardware.loading")}
</p>
);
}
// a hand-written config can name hardware this system does not report
const unrecognized = devices.length > 0 && !selected;
return (
<div className="space-y-6">
<div className="space-y-1">
<Label>{t("detectionModels.hardware.label")}</Label>
<Select
value={selected?.key ?? ""}
onValueChange={handleHardwareChange}
disabled={disabled}
>
<SelectTrigger className="max-w-xs">
<SelectValue
placeholder={t("detectionModels.hardware.placeholder")}
/>
</SelectTrigger>
<SelectContent>
{(hardware ?? []).map((entry) => (
<SelectItem key={entry.key} value={entry.key}>
{entry.name}
{entry.count > 1 ? ` (${entry.count})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("detectionModels.hardware.description")}
</p>
</div>
{unrecognized ? (
<p className="text-sm text-danger">
{t("detectionModels.hardware.unrecognized", {
devices: devices.join(", "),
})}
</p>
) : null}
{selected && (selected.units.length > 1 || !selected.unlimited) ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{t("detectionModels.hardware.unitsDescription")}
</p>
{selected.units.map((unit) => {
const claimedBy = claimedElsewhere[unit.device];
return (
<div
key={unit.device}
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
>
<Checkbox
id={`${idPrefix}-${unit.device}`}
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={devices.includes(unit.device)}
disabled={disabled || Boolean(claimedBy)}
onCheckedChange={(checked) =>
handleUnitToggle(unit.device, checked === true)
}
/>
<Label
htmlFor={`${idPrefix}-${unit.device}`}
className="cursor-pointer font-normal"
>
{unit.label}
{claimedBy ? (
<span className="ml-2 text-xs text-muted-foreground">
{t("detectionModels.hardware.claimedBy", {
scene: claimedBy,
})}
</span>
) : null}
</Label>
</div>
);
})}
</div>
) : null}
{selected?.unlimited ? (
<div className="space-y-1">
<Label htmlFor={`${idPrefix}-detector-count`}>
{t("detectionModels.hardware.detectorCount")}
</Label>
<Select
value={String(devices.length || 1)}
onValueChange={handleCountChange}
disabled={disabled}
>
<SelectTrigger
id={`${idPrefix}-detector-count`}
className="max-w-xs"
>
{String(devices.length || 1)}
</SelectTrigger>
<SelectContent>
{Array.from({ length: MAX_DETECTORS }, (_, index) => index + 1)
.filter((count) => count >= Math.max(selectedUnits.length, 1))
.map((count) => (
<SelectItem key={count} value={String(count)}>
<div className="flex h-max flex-col justify-between">
<div>{count}</div>
{count === recommended ? (
<div className="text-xs text-muted-foreground">
{t("detectionModels.hardware.countRecommended", {
count: cameraCount,
})}
</div>
) : null}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("detectionModels.hardware.detectorCountDescription")}
</p>
</div>
) : null}
</div>
);
}
export default HardwarePicker;
@@ -0,0 +1,175 @@
import { ReactNode, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import useSWR from "swr";
import axios from "axios";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { FrigateConfig } from "@/types/frigateConfig";
export type FrigatePlusModel = {
id: string;
name: string;
baseModel: string;
trainDate: string;
isBaseModel: boolean;
supportedDetectors: string[];
width: number;
height: number;
};
const PLUS_PREFIX = "plus://";
/** The Frigate+ model id a path refers to, if it is a Frigate+ path at all. */
function plusModelId(path: unknown): string | undefined {
return typeof path === "string" && path.startsWith(PLUS_PREFIX)
? path.slice(PLUS_PREFIX.length)
: undefined;
}
type ModelSourcePickerProps = {
path: unknown;
// Frigate+ metadata the backend attaches to a saved model, and the only
// reliable signal that one is active: it resolves `plus://<id>` to a local
// cache path before serving the config back
plus?: { id: string } | null;
// the detector this model runs on, used to filter incompatible Plus models
detector?: string;
disabled?: boolean;
onPathChange: (path: string | undefined) => void;
// the schema-driven fields for a custom model
customFields: ReactNode;
};
export function ModelSourcePicker({
path,
plus,
detector,
disabled,
onPathChange,
customFields,
}: ModelSourcePickerProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config");
const plusEnabled = Boolean(config?.plus?.enabled);
// an unsaved pick still carries the plus:// path, which wins over the
// metadata of whatever model was saved before it
const selectedId = plusModelId(path) ?? plus?.id;
const { data: availableModels, isLoading } = useSWR<
Record<string, FrigatePlusModel>
>(plusEnabled ? "/plus/models" : null, {
fetcher: async (url) => {
const res = await axios.get(url, { withCredentials: true });
return res.data.reduce(
(obj: Record<string, FrigatePlusModel>, model: FrigatePlusModel) => {
obj[model.id] = model;
return obj;
},
{},
);
},
});
const entries = useMemo(
() => Object.entries(availableModels ?? {}),
[availableModels],
);
// the tab cannot be derived from the path alone: switching to Frigate+
// leaves the path untouched until a model is picked
const [tab, setTab] = useState<"plus" | "custom">(
selectedId ? "plus" : "custom",
);
const handleTabChange = (value: string) => {
setTab(value as "plus" | "custom");
// a resolved Frigate+ path is meaningless as a custom path, so drop it
if (value === "custom" && selectedId) {
onPathChange(undefined);
}
};
const isCompatible = (model: FrigatePlusModel) =>
!detector || model.supportedDetectors.includes(detector);
if (!plusEnabled) {
return <div className="space-y-6">{customFields}</div>;
}
const describe = (model: FrigatePlusModel) =>
`${new Date(model.trainDate).toLocaleString()} ${model.baseModel} (${
model.isBaseModel
? t("frigatePlus.modelInfo.plusModelType.baseModel")
: t("frigatePlus.modelInfo.plusModelType.userModel")
}) ${model.name} (${model.width}x${model.height})`;
return (
<Tabs value={tab} onValueChange={handleTabChange}>
<TabsList className="mb-4">
<TabsTrigger value="plus">{t("detectionModels.tabs.plus")}</TabsTrigger>
<TabsTrigger value="custom">
{t("detectionModels.tabs.custom")}
</TabsTrigger>
</TabsList>
<TabsContent value="plus" className="space-y-1">
<Label>{t("frigatePlus.modelInfo.availableModels")}</Label>
<Select
value={selectedId ?? ""}
onValueChange={(id) => onPathChange(`${PLUS_PREFIX}${id}`)}
disabled={disabled}
>
<SelectTrigger className="w-full max-w-2xl">
{selectedId && availableModels?.[selectedId]
? describe(availableModels[selectedId])
: isLoading
? t("frigatePlus.modelInfo.loadingAvailableModels")
: t("detectionModels.plusModel.noModelSelected")}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{entries.length === 0 ? (
<div className="px-4 py-3 text-center text-sm text-muted-foreground">
{t("frigatePlus.modelInfo.noModelsAvailable")}
</div>
) : (
entries.map(([id, model]) => (
<SelectItem
key={id}
className="cursor-pointer"
value={id}
disabled={!isCompatible(model)}
>
<div>{describe(model)}</div>
<div className="text-xs text-muted-foreground">
{t("frigatePlus.modelInfo.supportedDetectors")}:{" "}
{model.supportedDetectors.join(", ")}
</div>
</SelectItem>
))
)}
</SelectGroup>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
<Trans ns="views/settings">frigatePlus.modelInfo.modelSelect</Trans>
</p>
</TabsContent>
<TabsContent value="custom" className="space-y-6">
{customFields}
</TabsContent>
</Tabs>
);
}
export default ModelSourcePicker;
@@ -0,0 +1,460 @@
import type {
ErrorSchema,
FieldProps,
RJSFSchema,
UiSchema,
} from "@rjsf/utils";
import { toFieldPathId } from "@rjsf/utils";
import { cloneDeep } from "lodash";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
LuChevronDown,
LuChevronRight,
LuPlus,
LuTrash2,
} from "react-icons/lu";
import { applySchemaDefaults } from "@/lib/config-schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ConfigFormContext } from "@/types/configForm";
import useSWR from "swr";
import { DetectionHardware } from "@/types/hardware";
import { summarizeDevices } from "@/utils/detectionHardware";
import { HardwarePicker } from "./HardwarePicker";
import { ModelSourcePicker } from "./ModelSourcePicker";
type DetectionModel = {
scene?: string;
devices?: string[];
[key: string]: unknown;
};
// scene and devices get dedicated controls; everything else is the model itself
const CUSTOM_MODEL_FIELDS = [
"path",
"labelmap_path",
"width",
"height",
"input_pixel_format",
"input_tensor",
"input_dtype",
"model_type",
];
/** The detector a model runs on, which is the prefix of its device strings. */
const detectorForModel = (model: DetectionModel): string | undefined =>
model.devices?.[0]?.split(":")[0];
const asModelList = (formData: unknown): DetectionModel[] => {
if (!Array.isArray(formData)) {
return [];
}
return formData.filter(
(item): item is DetectionModel => typeof item === "object" && item !== null,
);
};
const getItemSchema = (schema: RJSFSchema): RJSFSchema | undefined => {
const items = schema.items;
if (!items || typeof items !== "object" || Array.isArray(items)) {
return undefined;
}
return items as RJSFSchema;
};
const getItemProperties = (
schema: RJSFSchema | undefined,
): Record<string, RJSFSchema> => {
if (!schema || typeof schema.properties !== "object" || !schema.properties) {
return {};
}
return schema.properties as Record<string, RJSFSchema>;
};
const getSceneOptions = (itemSchema: RJSFSchema | undefined): string[] => {
const scene = getItemProperties(itemSchema).scene as
| Record<string, unknown>
| undefined;
const values = scene?.enum;
return Array.isArray(values)
? values.filter((v): v is string => typeof v === "string")
: [];
};
export function ModelsField(props: FieldProps) {
const {
schema,
uiSchema,
formData,
onChange,
fieldPathId,
registry,
idSchema,
errorSchema,
disabled,
readonly,
hideError,
onBlur,
onFocus,
} = props;
const { t } = useTranslation(["views/settings", "common"]);
const formContext = registry?.formContext as ConfigFormContext | undefined;
const models = useMemo(() => asModelList(formData), [formData]);
const itemSchema = useMemo(
() => getItemSchema(schema as RJSFSchema),
[schema],
);
const itemProperties = useMemo(
() => getItemProperties(itemSchema),
[itemSchema],
);
const itemUiSchema = useMemo(
() =>
((uiSchema as { items?: UiSchema } | undefined)?.items ?? {}) as UiSchema,
[uiSchema],
);
const sceneOptions = useMemo(() => getSceneOptions(itemSchema), [itemSchema]);
const SchemaField = registry.fields.SchemaField;
const [openByIndex, setOpenByIndex] = useState<Record<number, boolean>>({});
// shared with HardwarePicker through the SWR cache, so this is not a second
// request
const { data: hardware } = useSWR<DetectionHardware[]>("hardware/probe");
useEffect(() => {
setOpenByIndex((previous) => {
const next: Record<number, boolean> = {};
for (let index = 0; index < models.length; index += 1) {
next[index] = previous[index] ?? true;
}
return next;
});
}, [models.length]);
const cameras = formContext?.fullConfig?.cameras;
const savedModels = formContext?.fullConfig?.models;
// `plus` is a readonly field stripped from the form data, so read it from the
// full config. Match on scene rather than index, which shifts when a model is
// added or removed.
const savedPlusForScene = useCallback(
(scene: string | undefined) =>
savedModels?.find((saved) => saved.scene === scene)?.plus,
[savedModels],
);
// a model serves the cameras naming its scene, plus every camera that names
// no scene at all when it is the "all" model
const cameraCountForScene = useCallback(
(scene: string | undefined): number => {
if (!cameras) {
return 0;
}
return Object.values(cameras).filter((camera) => {
const cameraScene = camera?.detect?.scene;
return cameraScene ? cameraScene === scene : scene === "all";
}).length;
},
[cameras],
);
const claimedByOtherModels = useCallback(
(index: number): Record<string, string> => {
const claimed: Record<string, string> = {};
models.forEach((model, currentIndex) => {
if (currentIndex === index) {
return;
}
(model.devices ?? []).forEach((device) => {
claimed[device] = model.scene ?? String(currentIndex + 1);
});
});
return claimed;
},
[models],
);
const updateModel = useCallback(
(index: number, partial: Partial<DetectionModel>) => {
const next = cloneDeep(models);
next[index] = { ...next[index], ...partial };
onChange(next, fieldPathId.path);
},
[models, onChange, fieldPathId.path],
);
const handleAddModel = useCallback(() => {
const base = itemSchema
? (applySchemaDefaults(itemSchema) as DetectionModel)
: ({} as DetectionModel);
const taken = new Set(models.map((model) => model.scene));
const scene = sceneOptions.find((option) => !taken.has(option));
onChange([...models, { ...base, scene, devices: [] }], fieldPathId.path);
setOpenByIndex((previous) => ({ ...previous, [models.length]: true }));
}, [models, itemSchema, sceneOptions, onChange, fieldPathId.path]);
const handleRemoveModel = useCallback(
(index: number) => {
onChange(
models.filter((_, currentIndex) => currentIndex !== index),
fieldPathId.path,
);
setOpenByIndex((previous) => {
const next: Record<number, boolean> = {};
Object.entries(previous).forEach(([key, value]) => {
const current = Number(key);
if (Number.isNaN(current) || current === index) {
return;
}
next[current > index ? current - 1 : current] = value;
});
return next;
});
},
[models, onChange, fieldPathId.path],
);
const renderField = useCallback(
(index: number, fieldName: string) => {
const fieldSchema = itemProperties[fieldName];
if (!SchemaField || !fieldSchema) {
return null;
}
const itemFieldPathId = toFieldPathId(
fieldName,
registry.globalFormOptions,
[...fieldPathId.path, index],
);
const itemErrors = (
errorSchema as Record<string, ErrorSchema> | undefined
)?.[index] as Record<string, ErrorSchema> | undefined;
return (
<SchemaField
key={fieldName}
name={fieldName}
schema={fieldSchema}
uiSchema={(itemUiSchema[fieldName] as UiSchema | undefined) ?? {}}
fieldPathId={itemFieldPathId}
formData={(models[index] as Record<string, unknown>)?.[fieldName]}
errorSchema={itemErrors?.[fieldName]}
onChange={(nextValue: unknown) =>
updateModel(index, { [fieldName]: nextValue })
}
onBlur={onBlur}
onFocus={onFocus}
registry={registry}
disabled={disabled}
readonly={readonly}
hideError={hideError}
/>
);
},
[
SchemaField,
itemProperties,
itemUiSchema,
models,
registry,
fieldPathId.path,
errorSchema,
updateModel,
onBlur,
onFocus,
disabled,
readonly,
hideError,
],
);
const baseId = idSchema?.$id ?? "models";
return (
<div className="space-y-4">
{models.map((model, index) => {
const open = openByIndex[index] ?? true;
const takenScenes = new Set(
models
.filter((_, currentIndex) => currentIndex !== index)
.map((other) => other.scene),
);
return (
<Card key={`${baseId}-${index}`} className="w-full">
<Collapsible
open={open}
onOpenChange={(nextOpen) =>
setOpenByIndex((previous) => ({
...previous,
[index]: nextOpen,
}))
}
>
<CardHeader className="p-4">
<div className="flex items-center justify-between gap-4">
<CollapsibleTrigger asChild>
<CardTitle className="flex-1 cursor-pointer text-sm">
<span>
{t(`detectionModels.scenes.${model.scene ?? "all"}`)}
</span>
<span className="mt-1 block text-xs font-normal text-muted-foreground">
{summarizeDevices(
hardware ?? [],
model.devices ?? [],
) ?? t("detectionModels.hardware.none")}
{" • "}
{t("detectionModels.cameras", {
count: cameraCountForScene(model.scene),
})}
</span>
</CardTitle>
</CollapsibleTrigger>
<div className="flex shrink-0 items-center gap-1">
{models.length > 1 ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveModel(index)}
disabled={disabled || readonly}
aria-label={t("button.delete", { ns: "common" })}
>
<LuTrash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("button.delete", { ns: "common" })}
</TooltipContent>
</Tooltip>
) : null}
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t(
open ? "button.collapse" : "button.expand",
{ ns: "common" },
)}
>
{open ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
</div>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="space-y-6 p-4 pt-0">
<div className="space-y-1">
<Label>{t("detectionModels.scene.label")}</Label>
<Select
value={model.scene ?? ""}
onValueChange={(scene) => updateModel(index, { scene })}
disabled={disabled || readonly}
>
<SelectTrigger className="max-w-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sceneOptions.map((scene) => (
<SelectItem
key={scene}
value={scene}
disabled={takenScenes.has(scene)}
>
{t(`detectionModels.scenes.${scene}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("detectionModels.scene.description")}
</p>
</div>
<HardwarePicker
idPrefix={`${baseId}-${index}`}
devices={model.devices ?? []}
claimedElsewhere={claimedByOtherModels(index)}
cameraCount={cameraCountForScene(model.scene)}
disabled={disabled || readonly}
onChange={(devices) => updateModel(index, { devices })}
/>
<ModelSourcePicker
path={model.path}
plus={savedPlusForScene(model.scene)}
detector={detectorForModel(model)}
disabled={disabled || readonly}
onPathChange={(path) => updateModel(index, { path })}
customFields={CUSTOM_MODEL_FIELDS.map((fieldName) =>
renderField(index, fieldName),
)}
/>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
})}
{models.length < sceneOptions.length ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddModel}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("detectionModels.addModel")}
</Button>
) : null}
</div>
);
}
export default ModelsField;
@@ -1,5 +1,5 @@
// Custom RJSF Fields
export { LayoutGridField } from "./LayoutGridField";
export { DetectorHardwareField } from "./DetectorHardwareField";
export { ModelsField } from "./ModelsField";
export { ReplaceRulesField } from "./ReplaceRulesField";
export { LiveStreamsField } from "./LiveStreamsField";
@@ -49,7 +49,7 @@ import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate";
import { WrapIfAdditionalTemplate } from "./templates/WrapIfAdditionalTemplate";
import { LayoutGridField } from "./fields/LayoutGridField";
import { DetectorHardwareField } from "./fields/DetectorHardwareField";
import { ModelsField } from "./fields/ModelsField";
import { ReplaceRulesField } from "./fields/ReplaceRulesField";
import { CameraInputsField } from "./fields/CameraInputsField";
import { DictAsYamlField } from "./fields/DictAsYamlField";
@@ -111,7 +111,7 @@ export const frigateTheme: FrigateTheme = {
},
fields: {
LayoutGridField: LayoutGridField,
DetectorHardwareField: DetectorHardwareField,
ModelsField: ModelsField,
ReplaceRulesField: ReplaceRulesField,
CameraInputsField: CameraInputsField,
DictAsYamlField: DictAsYamlField,
@@ -111,11 +111,7 @@ const resolveErrorFieldLabel = ({
? "config/cameras"
: formContext?.i18nNamespace;
const translationPath = buildTranslationPath(
stringSegments,
sectionI18nPrefix,
formContext,
);
const translationPath = buildTranslationPath(stringSegments, formContext);
if (effectiveNamespace && translationPath) {
const translated = resolveConfigTranslation(
@@ -168,11 +168,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
(!isArrayItemInAdditionalProp || showArrayItemDescription) &&
!suppressDescription;
const translationPath = buildTranslationPath(
pathSegments,
sectionI18nPrefix,
formContext,
);
const translationPath = buildTranslationPath(pathSegments, formContext);
const fieldPath = fieldPathId.path;
const overrides = formContext?.overrides;
const baselineFormData = formContext?.baselineFormData;
@@ -252,11 +252,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
? getTranslatedLabel(filterObjectLabel, isAudioLabels ? "audio" : "object")
: undefined;
if (path) {
translationPath = buildTranslationPath(
path,
sectionI18nPrefix,
formContext,
);
translationPath = buildTranslationPath(path, formContext);
// Also get the last property name for fallback label generation
for (let i = path.length - 1; i >= 0; i -= 1) {
const segment = path[i];
@@ -8,62 +8,20 @@
import type { ConfigFormContext } from "@/types/configForm";
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const resolveDetectorType = (
detectorConfig: unknown,
detectorKey?: string,
): string | undefined => {
if (!detectorKey || !isRecord(detectorConfig)) {
return undefined;
}
const entry = detectorConfig[detectorKey];
if (!isRecord(entry)) {
return undefined;
}
const typeValue = entry.type;
return typeof typeValue === "string" && typeValue.length > 0
? typeValue
: undefined;
};
const resolveDetectorTypeFromContext = (
formContext: ConfigFormContext | undefined,
detectorKey?: string,
): string | undefined => {
const formData = formContext?.formData;
if (!detectorKey || !isRecord(formData)) {
return undefined;
}
const detectorConfig = isRecord(formData.detectors)
? formData.detectors
: formData;
return resolveDetectorType(detectorConfig, detectorKey);
};
/**
* Build the i18n translation key path for nested fields using the field path
* provided by RJSF. This avoids ambiguity with underscores in field names and
* normalizes dynamic segments like filter object names or detector names.
* normalizes dynamic segments like filter object names.
*
* @param segments Array of path segments (strings and/or numbers)
* @param sectionI18nPrefix Optional section prefix for specialized sections
* @param formContext Optional form context for resolving detector types
* @param formContext Optional form context for resolving attribute labels
* @returns Normalized translation key path as a dot-separated string
*
* @example
* buildTranslationPath(["filters", "person", "threshold"]) => "filters.threshold"
* buildTranslationPath(["detectors", "ov1", "type"]) => "detectors.openvino.type"
* buildTranslationPath(["ov1", "type"], "detectors") => "openvino.type"
*/
export function buildTranslationPath(
segments: Array<string | number>,
sectionI18nPrefix?: string,
formContext?: ConfigFormContext,
): string {
// Filter out numeric indices to get string segments only
@@ -97,46 +55,6 @@ export function buildTranslationPath(
return normalized.join(".");
}
// Handle detectors section - resolve the detector type when available
// Example: detectors.ov1.type -> detectors.openvino.type
const detectorsIndex = stringSegments.indexOf("detectors");
if (detectorsIndex !== -1 && stringSegments.length > detectorsIndex + 2) {
const detectorKey = stringSegments[detectorsIndex + 1];
const detectorType = resolveDetectorTypeFromContext(
formContext,
detectorKey,
);
if (detectorType) {
const normalized = [
...stringSegments.slice(0, detectorsIndex + 1),
detectorType,
...stringSegments.slice(detectorsIndex + 2),
];
return normalized.join(".");
}
const normalized = [
...stringSegments.slice(0, detectorsIndex + 1),
...stringSegments.slice(detectorsIndex + 2),
];
return normalized.join(".");
}
// Handle specialized sections like detectors where the first segment is dynamic
// Example: (sectionI18nPrefix="detectors") "ov1.type" -> "openvino.type"
if (sectionI18nPrefix === "detectors" && stringSegments.length > 1) {
const detectorKey = stringSegments[0];
const detectorType = resolveDetectorTypeFromContext(
formContext,
detectorKey,
);
if (detectorType) {
return [detectorType, ...stringSegments.slice(1)].join(".");
}
return stringSegments.slice(1).join(".");
}
return stringSegments.join(".");
}
+7
View File
@@ -736,6 +736,13 @@ export function applySchemaDefaults(
schema: RJSFSchema,
formData: Record<string, unknown> = {},
): Record<string, unknown> {
// An array section (models) carries its defaults on the item schema, not
// here. Spreading an array below would turn it into an object keyed by
// index, so hand it back untouched.
if (Array.isArray(formData)) {
return formData as unknown as Record<string, unknown>;
}
const result = { ...formData };
const schemaObj = schema as Record<string, unknown>;
+5 -118
View File
@@ -51,7 +51,6 @@ import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
import MediaSyncSettingsView from "@/views/settings/MediaSyncSettingsView";
import RegionGridSettingsView from "@/views/settings/RegionGridSettingsView";
import Go2RtcStreamsSettingsView from "@/views/settings/Go2RtcStreamsSettingsView";
import DetectorsAndModelSettingsView from "@/views/settings/DetectorsAndModelSettingsView";
import {
SingleSectionPage,
type SettingsPageProps,
@@ -96,14 +95,10 @@ import { mutate } from "swr";
import { RJSFSchema } from "@rjsf/utils";
import {
buildConfigDataForPath,
buildHiddenFieldContext,
flattenOverrides,
getSectionConfig,
parseProfileFromSectionPath,
prepareSectionSavePayload,
PROFILE_ELIGIBLE_SECTIONS,
resolveHiddenFieldEntries,
sanitizeSectionData,
} from "@/utils/configUtil";
import type { ProfileState, ProfilesApiResponse } from "@/types/profile";
import { getProfileColor } from "@/utils/profileColors";
@@ -115,7 +110,6 @@ import SaveAllPreviewPopover, {
type SaveAllPreviewItem,
} from "@/components/overlay/detail/SaveAllPreviewPopover";
import { useRestart } from "@/api/ws";
import { getPrimaryModel } from "@/utils/modelUtil";
import {
Tooltip,
TooltipContent,
@@ -246,6 +240,7 @@ const SystemEnvironmentVariablesSettingsPage = createSectionPage(
);
const SystemTelemetrySettingsPage = createSectionPage("telemetry", "global");
const SystemBirdseyeSettingsPage = createSectionPage("birdseye", "global");
const SystemDetectionModelsPage = createSectionPage("models", "global");
const NotificationsSettingsPage = createSectionPage("notifications", "global");
const SystemMqttSettingsPage = createSectionPage("mqtt", "global");
@@ -408,7 +403,7 @@ const settingsGroups = [
},
{
key: "systemDetectorsAndModel",
component: DetectorsAndModelSettingsView,
component: SystemDetectionModelsPage,
},
{ key: "systemDatabase", component: SystemDatabaseSettingsPage },
{ key: "systemMqtt", component: SystemMqttSettingsPage },
@@ -560,8 +555,7 @@ const SYSTEM_SECTION_MAPPING: Record<string, SettingsType> = {
environment_vars: "systemEnvironmentVariables",
telemetry: "systemTelemetry",
birdseye: "systemBirdseye",
detectors: "systemDetectorsAndModel",
model: "systemDetectorsAndModel",
models: "systemDetectorsAndModel",
};
const CAMERA_SECTION_KEYS = new Set<SettingsType>(
@@ -877,8 +871,7 @@ export default function Settings() {
// Show save/undo all buttons only when at least one pending change lives
// outside the currently visible page. Map each pending key to its menu key
// (e.g. both `detectors` and `model` collapse to `systemDetectorsAndModel`)
// so a composite page with two pending config-sections still counts as one.
// so a page hosting several config-sections still counts as one.
const showSaveAllButtons = useMemo(() => {
const pendingKeys = Object.keys(pendingDataBySection);
if (pendingKeys.length === 0) return false;
@@ -912,111 +905,6 @@ export default function Settings() {
// after `mutate("config")` resolves
const keysToClear: string[] = [];
// `detectors` and `model` are owned by DetectorsAndModelSettingsView
const hasPendingDetectors = "detectors" in pendingDataBySection;
const hasPendingModel = "model" in pendingDataBySection;
if (hasPendingDetectors || hasPendingModel) {
try {
const pendingDetectors = hasPendingDetectors
? pendingDataBySection.detectors
: undefined;
const pendingModel = hasPendingModel
? pendingDataBySection.model
: undefined;
// Hidden-field lists come from the section configs themselves so
// they stay in sync with what the embedded forms strip on render
const detectorHiddenFields = resolveHiddenFieldEntries(
getSectionConfig("detectors", "global").hiddenFields,
buildHiddenFieldContext(config, "global"),
);
const modelHiddenFields = resolveHiddenFieldEntries(
getSectionConfig("model", "global").hiddenFields,
buildHiddenFieldContext(config, "global"),
);
const sanitizedDetectors =
pendingDetectors !== undefined
? sanitizeSectionData(pendingDetectors, detectorHiddenFields)
: undefined;
const sanitizedModel =
pendingModel !== undefined
? sanitizeSectionData(pendingModel, modelHiddenFields)
: undefined;
// Pre-clear conditions: detector keys differ from saved config (rename
// or add/remove), OR the model save flips between Plus and Custom modes
let detectorKeysChanged = false;
if (sanitizedDetectors && typeof sanitizedDetectors === "object") {
const pendingKeySet = Object.keys(
sanitizedDetectors as JsonObject,
).sort();
const savedKeySet = [
...(getPrimaryModel(config)?.devices ?? []),
].sort();
detectorKeysChanged =
JSON.stringify(pendingKeySet) !== JSON.stringify(savedKeySet);
}
let modelTabChanged = false;
if (sanitizedModel && typeof sanitizedModel === "object") {
const newPath = (sanitizedModel as { path?: string }).path;
const oldPath = getPrimaryModel(config)?.path;
const newIsPlus =
typeof newPath === "string" && newPath.startsWith("plus://");
const oldIsPlus =
typeof oldPath === "string" && oldPath.startsWith("plus://");
modelTabChanged = newIsPlus !== oldIsPlus;
}
if (detectorKeysChanged || modelTabChanged) {
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: { detectors: null, model: null },
});
} catch {
// best-effort cleanup; the merge-write below will surface any
// real error.
}
}
const combinedConfigData: Record<string, unknown> = {};
if (sanitizedDetectors !== undefined) {
combinedConfigData.detectors = sanitizedDetectors;
}
if (sanitizedModel !== undefined) {
combinedConfigData.model = sanitizedModel;
}
await axios.put("config/set", {
requires_restart: 0,
config_data: combinedConfigData,
});
if (hasPendingDetectors) {
keysToClear.push("detectors");
savedKeys.push("detectors");
}
if (hasPendingModel) {
keysToClear.push("model");
savedKeys.push("model");
}
if (hasPendingDetectors || hasPendingModel) {
successCount++;
anyNeedsRestart = true;
}
} catch (error) {
// eslint-disable-next-line no-console
console.error(
"Save All – error saving detectors/model atomically",
error,
);
if (hasPendingDetectors || hasPendingModel) {
failCount++;
}
}
}
// go2rtc streams are owned by Go2RtcStreamsSettingsView
if ("go2rtc_streams" in pendingDataBySection) {
try {
@@ -1067,8 +955,7 @@ export default function Settings() {
}
const pendingKeys = Object.keys(pendingDataBySection).filter(
(key) =>
key !== "detectors" && key !== "model" && key !== "go2rtc_streams",
(key) => key !== "go2rtc_streams",
);
for (const key of pendingKeys) {
+1 -1
View File
@@ -66,7 +66,7 @@ export interface CameraConfig {
height: number;
max_disappeared: number;
min_initialized: number;
scene: string | null;
scene: string;
stationary: {
interval: number;
max_frames: {
+13
View File
@@ -0,0 +1,13 @@
export type HardwareUnit = {
device: string;
label: string;
};
export type DetectionHardware = {
key: string;
detector: string;
name: string;
units: HardwareUnit[];
count: number;
unlimited: boolean;
};
+20
View File
@@ -220,6 +220,26 @@ export function buildOverrides(
) {
return undefined;
}
// Same-length arrays get compared element by element rather than by
// identity, so an item carrying an explicit null where the base simply
// omits the key does not read as a change. `/api/config` serializes with
// exclude_none, so every nullable field a form materializes would
// otherwise look edited the moment the page opens.
if (Array.isArray(base) && base.length === current.length) {
const baseItems = base;
const defaultItems = Array.isArray(defaults) ? defaults : undefined;
const unchanged = current.every(
(item, index) =>
buildOverrides(item, baseItems[index], defaultItems?.[index]) ===
undefined,
);
if (unchanged) {
return undefined;
}
}
return current;
}
+62
View File
@@ -0,0 +1,62 @@
import { DetectionHardware } from "@/types/hardware";
// one detector per this many cameras, so the recommendation grows with the
// install without spawning a process per camera
const CAMERAS_PER_DETECTOR = 8;
export const MAX_DETECTORS = 8;
/** How many detectors to suggest for a model serving this many cameras. */
export function recommendedDetectorCount(cameraCount: number): number {
const scaled = Math.ceil(cameraCount / CAMERAS_PER_DETECTOR);
return Math.min(Math.max(scaled, 1), MAX_DETECTORS);
}
/** The hardware whose units cover every one of these device strings. */
export function hardwareForDevices(
hardware: DetectionHardware[],
devices: string[],
): DetectionHardware | undefined {
if (devices.length === 0) {
return undefined;
}
return hardware.find((entry) => {
const known = new Set(entry.units.map((unit) => unit.device));
return devices.every((device) => known.has(device));
});
}
/**
* A short summary of the hardware a model runs on.
*
* Repeating a device is how extra inference processes are configured, so the
* raw list reads as "openvino:NPU, openvino:NPU". Collapse it to a name and a
* count instead.
*/
export function summarizeDevices(
hardware: DetectionHardware[],
devices: string[],
): string | undefined {
if (devices.length === 0) {
return undefined;
}
const known = hardwareForDevices(hardware, devices);
if (known) {
return devices.length > 1
? `${known.name} (${devices.length})`
: known.name;
}
// hardware this system does not report, so fall back to the raw strings
const counts = new Map<string, number>();
devices.forEach((device) =>
counts.set(device, (counts.get(device) ?? 0) + 1),
);
return [...counts.entries()]
.map(([device, count]) => (count > 1 ? `${device} ×${count}` : device))
.join(", ");
}
@@ -1,921 +0,0 @@
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { LuExternalLink, LuFilter } from "react-icons/lu";
import { toast } from "sonner";
import axios from "axios";
import useSWR from "swr";
import { useSWRConfig } from "swr";
import { cn } from "@/lib/utils";
import { useRestart } from "@/api/ws";
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import Heading from "@/components/ui/heading";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import type { FrigateConfig } from "@/types/frigateConfig";
import type {
SectionStatus,
SettingsPageProps,
} from "@/views/settings/SingleSectionPage";
import type { ConfigSectionData } from "@/types/configForm";
import {
SettingsGroupCard,
SplitCardRow,
} from "@/components/card/SettingsGroupCard";
import { ConfigSectionTemplate } from "@/components/config-form/sections";
import { ConfigMessageBanner } from "@/components/config-form/ConfigMessageBanner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getPrimaryModel } from "@/utils/modelUtil";
import {
buildHiddenFieldContext,
getSectionConfig,
resolveHiddenFieldEntries,
sanitizeSectionData,
} from "@/utils/configUtil";
type ModelTab = "plus" | "custom";
type PageState = {
detectors: ConfigSectionData;
modelTab: ModelTab;
plusModelId: string | undefined;
customModel: ConfigSectionData;
};
type FrigatePlusModel = {
id: string;
type: string;
name: string;
isBaseModel: boolean;
supportedDetectors: string[];
trainDate: string;
baseModel: string;
width: number;
height: number;
};
const TYPE_MODEL_DEFAULTS: Record<string, ConfigSectionData> = {
cpu: {
path: "/cpu_model.tflite",
labelmap_path: "/labelmap.txt",
width: 320,
height: 320,
input_tensor: "nhwc",
input_pixel_format: "rgb",
input_dtype: "int",
model_type: "ssd",
},
edgetpu: {
path: "/edgetpu_model.tflite",
labelmap_path: "/labelmap.txt",
width: 320,
height: 320,
input_tensor: "nhwc",
input_pixel_format: "rgb",
input_dtype: "int",
model_type: "ssd",
},
openvino: {
path: "/openvino-model/ssdlite_mobilenet_v2.xml",
labelmap_path: "/openvino-model/coco_91cl_bkgr.txt",
width: 300,
height: 300,
input_tensor: "nhwc",
input_pixel_format: "bgr",
input_dtype: "int",
model_type: "ssd",
},
};
const STATUS_BAR_KEY = "detectors_and_model";
const EMPTY_PENDING: Record<string, ConfigSectionData> = {};
const deriveInitialState = (config: FrigateConfig): PageState => {
const primaryModel = getPrimaryModel(config);
const plusModelId = primaryModel?.plus?.id;
const modelPath = primaryModel?.path;
const plusEnabled = Boolean(config.plus?.enabled);
// The reliable signal that a Plus model is currently active is the
// `model.plus.id` metadata
let modelTab: ModelTab;
if (plusModelId) {
modelTab = "plus";
} else if (typeof modelPath === "string" && modelPath.length > 0) {
modelTab = "custom";
} else if (plusEnabled) {
modelTab = "plus";
} else {
modelTab = "custom";
}
// Fallback: if Plus is not enabled, prefer Custom regardless of saved state
if (!plusEnabled && modelTab === "plus") {
modelTab = "custom";
}
const {
plus: _plus,
scene: _scene,
devices: _devices,
...modelWithoutPlus
} = (primaryModel ?? {}) as Record<string, unknown>;
// If a Plus model is active, the resolved `model.path` is auto-derived from
// `plus.id` — drop it so the Custom tab starts clean and doesn't silently
// re-save the same Plus model when the user thinks they switched modes.
if (plusModelId) {
delete modelWithoutPlus.path;
}
return {
detectors: { devices: primaryModel?.devices ?? [] } as ConfigSectionData,
modelTab,
plusModelId: plusModelId ?? undefined,
customModel: modelWithoutPlus as ConfigSectionData,
};
};
export default function DetectorsAndModelSettingsView({
setUnsavedChanges,
pendingDataBySection,
onPendingDataChange,
onSectionStatusChange,
isSavingAll,
onSectionSavingChange,
}: SettingsPageProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { getLocaleDocUrl } = useDocDomain();
const { data: config } = useSWR<FrigateConfig>("config");
const { mutate: globalMutate } = useSWRConfig();
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
// track the saved config
const snapshot = useMemo<PageState | null>(
() => (config ? deriveInitialState(config) : null),
[config],
);
const [state, setState] = useState<PageState | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [resetKey, setResetKey] = useState(0);
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const { send: sendRestart } = useRestart();
const childPending = pendingDataBySection ?? EMPTY_PENDING;
const [detectorStatus, setDetectorStatus] = useState<SectionStatus>({
hasChanges: false,
isOverridden: false,
hasValidationErrors: false,
});
const [modelStatus, setModelStatus] = useState<SectionStatus>({
hasChanges: false,
isOverridden: false,
hasValidationErrors: false,
});
const [showBaseModels, setShowBaseModels] = useState(true);
const [showFineTunedModels, setShowFineTunedModels] = useState(true);
const plusEnabled = Boolean(config?.plus?.enabled);
const { data: availableModels = {}, isLoading: isLoadingModels } = useSWR<
Record<string, FrigatePlusModel>
>(plusEnabled ? "/plus/models" : null, {
fallbackData: {},
fetcher: async (url) => {
const res = await axios.get(url, { withCredentials: true });
return res.data.reduce(
(obj: Record<string, FrigatePlusModel>, model: FrigatePlusModel) => {
obj[model.id] = model;
return obj;
},
{},
);
},
});
const filteredModelEntries = useMemo(
() =>
Object.entries(availableModels || {}).filter(([, model]) =>
model.isBaseModel ? showBaseModels : showFineTunedModels,
),
[availableModels, showBaseModels, showFineTunedModels],
);
const isFilterActive = !showBaseModels || !showFineTunedModels;
const detectorHiddenFields = useMemo(
() =>
resolveHiddenFieldEntries(
getSectionConfig("detectors", "global").hiddenFields,
buildHiddenFieldContext(config, "global"),
),
[config],
);
const modelHiddenFields = useMemo(
() =>
resolveHiddenFieldEntries(
getSectionConfig("model", "global").hiddenFields,
buildHiddenFieldContext(config, "global"),
),
[config],
);
const liveDetectors = useMemo(
() => childPending["detectors"] ?? snapshot?.detectors,
[childPending, snapshot],
);
const liveCustomModel = useMemo(
() => childPending["model"] ?? snapshot?.customModel,
[childPending, snapshot],
);
const currentDetectorType = useMemo(() => {
const values = Object.values(liveDetectors ?? {});
if (values.length === 0) return undefined;
const first = values[0] as { type?: string } | undefined;
return first?.type;
}, [liveDetectors]);
// fill in defaults when detector type changes
const prevDetectorTypeRef = useRef<string | undefined>(undefined);
useEffect(() => {
const newType = currentDetectorType;
const prevType = prevDetectorTypeRef.current;
prevDetectorTypeRef.current = newType;
if (prevType === undefined || prevType === newType) return;
if (!newType || !(newType in TYPE_MODEL_DEFAULTS)) return;
const defaults = TYPE_MODEL_DEFAULTS[newType];
onPendingDataChange?.("model", undefined, defaults);
if (newType === "openvino") {
const detectorsCurrent = (childPending.detectors ??
state?.detectors ??
{}) as {
[key: string]: { device?: string };
};
const entries = Object.entries(detectorsCurrent);
if (entries.length > 0) {
const [firstKey, firstValue] = entries[0];
if (!firstValue?.device) {
onPendingDataChange?.("detectors", undefined, {
...detectorsCurrent,
[firstKey]: { ...firstValue, device: "CPU" },
} as ConfigSectionData);
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentDetectorType]);
const isModelCompatible = useCallback(
(model: FrigatePlusModel) =>
currentDetectorType
? model.supportedDetectors.includes(currentDetectorType)
: true,
[currentDetectorType],
);
const selectedPlusModel = state?.plusModelId
? availableModels?.[state.plusModelId]
: undefined;
const plusMismatch =
state?.modelTab === "plus" &&
selectedPlusModel !== undefined &&
currentDetectorType !== undefined &&
!isModelCompatible(selectedPlusModel);
const plusModelMissing = state?.modelTab === "plus" && !state?.plusModelId;
const handleDetectorStatusChange = useCallback(
(status: SectionStatus) => {
setDetectorStatus(status);
onSectionStatusChange?.("detectors", "global", status);
},
[onSectionStatusChange],
);
// BaseSection drives `modelStatus` only when the Custom tab is mounted
const handleModelStatusChange = useCallback(
(status: SectionStatus) => setModelStatus(status),
[],
);
// report the *combined* model-section status to the parent
useEffect(() => {
if (!state || !snapshot) return;
const tabChanged = state.modelTab !== snapshot.modelTab;
const plusIdChanged =
state.modelTab === "plus" && state.plusModelId !== snapshot.plusModelId;
const pageLevelDirty = tabChanged || plusIdChanged;
onSectionStatusChange?.("model", "global", {
hasChanges: modelStatus.hasChanges || pageLevelDirty,
isOverridden: modelStatus.isOverridden,
overrideSource: modelStatus.overrideSource,
hasValidationErrors: modelStatus.hasValidationErrors,
});
}, [state, snapshot, modelStatus, onSectionStatusChange]);
// Tab toggle and Plus-model selection are page-local UI, but Save All and the
// sidebar dot live on `pendingDataBySection["model"]` and section status from
// the parent. These handlers mirror Plus-tab changes into both so a Plus-only
// edit (no custom-form typing) is still dirty and survives navigation.
const handleModelTabChange = useCallback(
(newTab: ModelTab) => {
setState((prev) => (prev ? { ...prev, modelTab: newTab } : prev));
if (!snapshot) return;
if (newTab === "plus") {
if (state?.plusModelId) {
onPendingDataChange?.("model", undefined, {
path: `plus://${state.plusModelId}`,
} as ConfigSectionData);
} else {
// No Plus model selected — clear any stale pending so the save
// action is correctly disabled until the user picks one.
onPendingDataChange?.("model", undefined, null);
}
} else {
// Switching to Custom: if pending["model"] still holds a plus path
// from a previous Plus selection, swap it for the snapshot's custom
// model so Save All writes the correct payload. Don't overwrite
// genuine custom-form edits the user typed earlier.
const currentPath = (
pendingDataBySection?.["model"] as { path?: string } | undefined
)?.path;
if (
typeof currentPath === "string" &&
currentPath.startsWith("plus://")
) {
onPendingDataChange?.(
"model",
undefined,
snapshot.customModel as ConfigSectionData,
);
}
}
},
[state?.plusModelId, snapshot, pendingDataBySection, onPendingDataChange],
);
const handlePlusModelIdChange = useCallback(
(newId: string) => {
setState((prev) => (prev ? { ...prev, plusModelId: newId } : prev));
onPendingDataChange?.("model", undefined, {
path: `plus://${newId}`,
} as ConfigSectionData);
},
[onPendingDataChange],
);
useEffect(() => {
if (!config || state !== null) return;
const initial = deriveInitialState(config);
// Restore Plus-tab UI state from any prior pending edits the user made
// before navigating away. `pendingDataBySection["model"]` is the source of
// truth for Save All; infer modelTab/plusModelId from it so the UI lines up.
const pendingModel = pendingDataBySection?.["model"] as
| { path?: string }
| undefined;
const pendingPath = pendingModel?.path;
if (typeof pendingPath === "string" && pendingPath.startsWith("plus://")) {
setState({
...initial,
modelTab: "plus",
plusModelId: pendingPath.slice("plus://".length) || undefined,
});
} else if (pendingModel && initial.modelTab === "plus") {
// There's a pending custom-model edit while the saved tab was Plus —
// means the user already switched to Custom before navigating away.
setState({ ...initial, modelTab: "custom" });
} else {
setState(initial);
}
}, [config, state, pendingDataBySection]);
const isDirty = useMemo(() => {
if (!state || !snapshot) return false;
if (state.modelTab !== snapshot.modelTab) return true;
if (state.plusModelId !== snapshot.plusModelId) return true;
if ("detectors" in childPending) return true;
if ("model" in childPending) return true;
return false;
}, [state, snapshot, childPending]);
useEffect(() => {
if (isDirty) {
addMessage(
STATUS_BAR_KEY,
t("detectorsAndModel.unsavedChanges"),
undefined,
STATUS_BAR_KEY,
);
} else {
removeMessage(STATUS_BAR_KEY, STATUS_BAR_KEY);
}
setUnsavedChanges?.(isDirty);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDirty]);
useEffect(() => {
document.title = t("documentTitle.detectorsAndModel");
}, [t]);
const onSave = useCallback(async () => {
if (!state || !snapshot) return;
const tabChanged = state.modelTab !== snapshot.modelTab;
// Strip computed/merged fields that the backend populates in /config
// responses but doesn't accept back on /config/set.
const sanitizedDetectors = sanitizeSectionData(
liveDetectors ?? {},
detectorHiddenFields,
);
const sanitizedCustomModel = sanitizeSectionData(
liveCustomModel ?? {},
modelHiddenFields,
);
const modelPayload =
state.modelTab === "plus"
? { path: `plus://${state.plusModelId}` }
: sanitizedCustomModel;
const detectorKeysChanged =
JSON.stringify(Object.keys(liveDetectors ?? {}).sort()) !==
JSON.stringify(Object.keys(snapshot.detectors).sort());
setIsSaving(true);
onSectionSavingChange?.(true);
let preCleared = false;
try {
// Pre-clear both `detectors` and `model` together when renaming
if (tabChanged || detectorKeysChanged) {
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: { detectors: null, model: null },
});
preCleared = true;
} catch {
// best-effort cleanup
}
}
await axios.put("config/set", {
requires_restart: 0,
config_data: {
detectors: sanitizedDetectors,
model: modelPayload,
},
});
await globalMutate("config");
await globalMutate("config/raw_paths");
// `snapshot` is derived from `config` via useMemo, so the awaited mutate
// above has already refreshed it. Just clear the pending entries — that
// resets isDirty since state should now match snapshot.
onPendingDataChange?.("detectors", undefined, null);
onPendingDataChange?.("model", undefined, null);
setResetKey((k) => k + 1);
addMessage(
"detectors_and_model_restart",
t("detectorsAndModel.restartRequired"),
undefined,
"detectors_and_model_restart",
);
toast.success(t("detectorsAndModel.toast.saveSuccess"), {
position: "top-center",
duration: 10000,
action: (
<Button onClick={() => setRestartDialogOpen(true)}>
{t("restart.button", { ns: "components/dialog" })}
</Button>
),
});
} catch (error) {
const err = error as {
response?: { data?: { message?: string; detail?: string } };
};
const message =
err.response?.data?.message ||
err.response?.data?.detail ||
t("detectorsAndModel.toast.saveError");
toast.error(message, { position: "top-center" });
if (preCleared) {
const restoreModel =
snapshot.modelTab === "plus" && snapshot.plusModelId
? { path: `plus://${snapshot.plusModelId}` }
: sanitizeSectionData(snapshot.customModel, modelHiddenFields);
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: {
detectors: sanitizeSectionData(
snapshot.detectors,
detectorHiddenFields,
),
model: restoreModel,
},
});
} catch {
// best-effort
}
}
// Re-sync the config cache to reflect whatever state the backend
// landed on after the failure (and any restore attempt).
await globalMutate("config");
} finally {
setIsSaving(false);
onSectionSavingChange?.(false);
}
}, [
state,
snapshot,
liveDetectors,
liveCustomModel,
detectorHiddenFields,
modelHiddenFields,
globalMutate,
onSectionSavingChange,
addMessage,
onPendingDataChange,
t,
]);
const onUndo = useCallback(() => {
if (snapshot) {
setState(snapshot);
onPendingDataChange?.("detectors", undefined, null);
onPendingDataChange?.("model", undefined, null);
// Force the embedded forms to re-mount so their internal dirty/baseline
// state is rebuilt from the current config — clearing pending alone
// doesn't reset BaseSection's internal tracking.
setResetKey((k) => k + 1);
}
}, [snapshot, onPendingDataChange]);
if (!config || !state) {
return <ActivityIndicator />;
}
const saveDisabled =
!isDirty ||
isSaving ||
isSavingAll ||
detectorStatus.hasValidationErrors ||
(state.modelTab === "custom" && modelStatus.hasValidationErrors) ||
plusMismatch ||
plusModelMissing;
return (
<div className="flex size-full flex-col md:pr-2">
<div className="mb-1 flex items-center justify-between gap-4 pt-2">
<div className="flex max-w-5xl flex-col">
<Heading as="h4">{t("detectorsAndModel.title")}</Heading>
<div className="my-1 text-sm text-muted-foreground">
{t("detectorsAndModel.description")}
</div>
<div className="flex items-center text-sm text-primary-variant">
<Link
to={getLocaleDocUrl("/configuration/object_detectors")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
{isDirty && (
<Badge
variant="secondary"
className="cursor-default bg-unsaved text-xs text-black hover:bg-unsaved"
>
{t("button.modified", { ns: "common", defaultValue: "Modified" })}
</Badge>
)}
</div>
<div className="w-full max-w-5xl space-y-6 pt-4">
<div className="space-y-6">
<SettingsGroupCard title={t("detectorsAndModel.cardTitles.detector")}>
<ConfigSectionTemplate
key={`detectors-${resetKey}`}
sectionKey="detectors"
level="global"
showOverrideIndicator={false}
showTitle={false}
embedded
pendingDataBySection={childPending}
onPendingDataChange={onPendingDataChange}
onStatusChange={handleDetectorStatusChange}
/>
</SettingsGroupCard>
{plusMismatch && selectedPlusModel && (
<ConfigMessageBanner
messages={[
{
key: "plus-mismatch",
messageKey: "detectorsAndModel.mismatch.warning",
severity: "warning",
condition: () => true,
values: {
model: selectedPlusModel.name,
required: selectedPlusModel.supportedDetectors.join(", "),
},
},
]}
/>
)}
<SettingsGroupCard title={t("detectorsAndModel.cardTitles.model")}>
{plusEnabled ? (
<Tabs
value={state.modelTab}
onValueChange={(value) =>
handleModelTabChange(value as ModelTab)
}
>
<TabsList className="mb-4">
<TabsTrigger value="plus">
{t("detectorsAndModel.tabs.plus")}
</TabsTrigger>
<TabsTrigger value="custom">
{t("detectorsAndModel.tabs.custom")}
</TabsTrigger>
</TabsList>
<TabsContent value="plus">
<SplitCardRow
label={t("frigatePlus.modelInfo.availableModels")}
description={
<Trans ns="views/settings">
frigatePlus.modelInfo.modelSelect
</Trans>
}
content={
<div className="flex w-full items-center gap-2">
<Select
value={state.plusModelId}
onValueChange={handlePlusModelIdChange}
>
<SelectTrigger className="w-full">
{state.plusModelId &&
availableModels?.[state.plusModelId]
? new Date(
availableModels[state.plusModelId].trainDate,
).toLocaleString() +
" " +
availableModels[state.plusModelId].baseModel +
" (" +
(availableModels[state.plusModelId].isBaseModel
? t(
"frigatePlus.modelInfo.plusModelType.baseModel",
)
: t(
"frigatePlus.modelInfo.plusModelType.userModel",
)) +
") " +
availableModels[state.plusModelId].name +
" (" +
availableModels[state.plusModelId].width +
"x" +
availableModels[state.plusModelId].height +
")"
: isLoadingModels
? t(
"frigatePlus.modelInfo.loadingAvailableModels",
)
: t(
"detectorsAndModel.plusModel.noModelSelected",
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{filteredModelEntries.length === 0 ? (
<div className="px-4 py-3 text-center text-sm text-muted-foreground">
{t("frigatePlus.modelInfo.noModelsAvailable")}
</div>
) : (
filteredModelEntries.map(([id, model]) => (
<SelectItem
key={id}
className="cursor-pointer"
value={id}
disabled={!isModelCompatible(model)}
>
{new Date(model.trainDate).toLocaleString()}{" "}
<div>
{model.baseModel} {" ("}
{model.isBaseModel
? t(
"frigatePlus.modelInfo.plusModelType.baseModel",
)
: t(
"frigatePlus.modelInfo.plusModelType.userModel",
)}
{")"}
</div>
<div>
{model.name} (
{model.width + "x" + model.height})
</div>
<div>
{t(
"frigatePlus.modelInfo.supportedDetectors",
)}
: {model.supportedDetectors.join(", ")}
</div>
{!isModelCompatible(model) && (
<div className="text-xs text-danger">
{t(
"detectorsAndModel.plusModel.requiresDetector",
{
detector:
model.supportedDetectors.join(
", ",
),
},
)}
</div>
)}
<div className="text-xs text-muted-foreground">
{id}
</div>
</SelectItem>
))
)}
</SelectGroup>
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="focus:outline-none"
aria-label={t(
"frigatePlus.modelInfo.filter.ariaLabel",
)}
>
<LuFilter
className={cn(
"size-4",
isFilterActive
? "text-selected"
: "text-secondary-foreground",
)}
/>
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56">
<div className="space-y-3">
<div className="text-sm text-primary-variant">
{t("frigatePlus.modelInfo.filter.ariaLabel")}
</div>
<div className="flex items-center justify-between">
<Label
htmlFor="filterBaseModels"
className="cursor-pointer text-primary"
>
{t("frigatePlus.modelInfo.filter.baseModels")}
</Label>
<Switch
id="filterBaseModels"
checked={showBaseModels}
onCheckedChange={setShowBaseModels}
/>
</div>
<div className="flex items-center justify-between">
<Label
htmlFor="filterFineTunedModels"
className="cursor-pointer text-primary"
>
{t(
"frigatePlus.modelInfo.filter.fineTunedModels",
)}
</Label>
<Switch
id="filterFineTunedModels"
checked={showFineTunedModels}
onCheckedChange={setShowFineTunedModels}
/>
</div>
</div>
</PopoverContent>
</Popover>
</div>
}
/>
</TabsContent>
<TabsContent value="custom">
<ConfigSectionTemplate
key={`model-${resetKey}`}
sectionKey="model"
level="global"
showOverrideIndicator={false}
showTitle={false}
embedded
pendingDataBySection={childPending}
onPendingDataChange={onPendingDataChange}
onStatusChange={handleModelStatusChange}
/>
</TabsContent>
</Tabs>
) : (
<ConfigSectionTemplate
key={`model-${resetKey}`}
sectionKey="model"
level="global"
showOverrideIndicator={false}
showTitle={false}
embedded
pendingDataBySection={childPending}
onPendingDataChange={onPendingDataChange}
onStatusChange={handleModelStatusChange}
/>
)}
</SettingsGroupCard>
</div>
</div>
<div className="sticky bottom-0 z-50 mt-6 w-full border-t border-secondary bg-background pt-0">
<div
className={cn(
"flex flex-col items-center gap-4 pt-2 md:flex-row",
isDirty ? "justify-between" : "justify-end",
)}
>
{isDirty && (
<span className="text-sm text-unsaved">
{t("unsavedChanges", { ns: "views/settings" })}
</span>
)}
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
{isDirty && (
<Button
onClick={onUndo}
variant="outline"
disabled={isSaving}
className="flex min-w-36 flex-1 gap-2"
>
{t("button.undo", { ns: "common" })}
</Button>
)}
<Button
onClick={onSave}
variant="select"
disabled={saveDisabled}
className="flex min-w-36 flex-1 gap-2"
>
{isSaving ? (
<>
<ActivityIndicator className="h-4 w-4" />
{t("button.saving", { ns: "common" })}
</>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</div>
<RestartDialog
isOpen={restartDialogOpen}
onClose={() => setRestartDialogOpen(false)}
onRestart={() => sendRestart("restart")}
/>
</div>
);
}