Full UI configuration (#22151)

* use react-jsonschema-form for UI config

* don't use properties wrapper when generating config i18n json

* configure for full i18n support

* section fields

* add descriptions to all fields for i18n

* motion i18n

* fix nullable fields

* sanitize internal fields

* add switches widgets and use friendly names

* fix nullable schema entries

* ensure update_topic is added to api calls

this needs further backend implementation to work correctly

* add global sections, camera config overrides, and reset button

* i18n

* add reset logic to global config view

* tweaks

* fix sections and live validation

* fix validation for schema objects that can be null

* generic and custom per-field validation

* improve generic error validation messages

* remove show advanced fields switch

* tweaks

* use shadcn theme

* fix array field template

* i18n tweaks

* remove collapsible around root section

* deep merge schema for advanced fields

* add array field item template and fix ffmpeg section

* add missing i18n keys

* tweaks

* comment out api call for testing

* add config groups as a separate i18n namespace

* add descriptions to all pydantic fields

* make titles more concise

* new titles as i18n

* update i18n config generation script to use json schema

* tweaks

* tweaks

* rebase

* clean up

* form tweaks

* add wildcards and fix object filter fields

* add field template for additionalproperties schema objects

* improve typing

* add section description from schema and clarify global vs camera level descriptions

* separate and consolidate global and camera i18n namespaces

* clean up now obsolete namespaces

* tweaks

* refactor sections and overrides

* add ability to render components before and after fields

* fix titles

* chore(sections): remove legacy single-section components replaced by template

* refactor configs to use individual files with a template

* fix review description

* apply hidden fields after ui schema

* move util

* remove unused i18n

* clean up error messages

* fix fast refresh

* add custom validation and use it for ffmpeg input roles

* update nav tree

* remove unused

* re-add override and modified indicators

* mark pending changes and add confirmation dialog for resets

* fix red unsaved dot

* tweaks

* add docs links, readonly keys, and restart required per field

* add special case and comments for global motion section

* add section form special cases

* combine review sections

* tweaks

* add audio labels endpoint

* add audio label switches and input to filter list

* fix type

* remove key from config when resetting to default/global

* don't show description for new key/val fields

* tweaks

* spacing tweaks

* add activity indicator and scrollbar tweaks

* add docs to filter fields

* wording changes

* fix global ffmpeg section

* add review classification zones to review form

* add backend endpoint and frontend widget for ffmpeg presets and manual args

* improve wording

* hide descriptions for additional properties arrays

* add warning log about incorrectly nested model config

* spacing and language tweaks

* fix i18n keys

* networking section docs and description

* small wording tweaks

* add layout grid field

* refactor with shared utilities

* field order

* add individual detectors to schema

add detector titles and descriptions (docstrings in pydantic are used for descriptions) and add i18n keys to globals

* clean up detectors section and i18n

* don't save model config back to yaml when saving detectors

* add full detectors config to api model dump

works around the way we use detector plugins so we can have the full detector config for the frontend

* add restart button to toast when restart is required

* add ui option to remove inner cards

* fix buttons

* section tweaks

* don't zoom into text on mobile

* make buttons sticky at bottom of sections

* small tweaks

* highlight label of changed fields

* add null to enum list when unwrapping

* refactor to shared utils and add save all button

* add undo all button

* add RJSF to dictionary

* consolidate utils

* preserve form data when changing cameras

* add mono fonts

* add popover to show what fields will be saved

* fix mobile menu not re-rendering with unsaved dots

* tweaks

* fix logger and env vars config section saving

use escaped periods in keys to retain them in the config file (eg "frigate.embeddings")

* add timezone widget

* role map field with validation

* fix validation for model section

* add another hidden field

* add footer message for required restart

* use rjsf for notifications view

* fix config saving

* add replace rules field

* default column layout and add field sizing

* clean up field template

* refactor profile settings to match rjsf forms

* tweaks

* refactor frigate+ view and make tweaks to sections

* show frigate+ model info in detection model settings when using a frigate+ model

* update restartRequired for all fields

* fix restart fields

* tweaks and add ability enable disabled cameras

more backend changes required

* require restart when enabling camera that is disabled in config

* disable save when form is invalid

* refactor ffmpeg section for readability

* change label

* clean up camera inputs fields

* misc tweaks to ffmpeg section

- add raw paths endpoint to ensure credentials get saved
- restart required tooltip

* maintenance settings tweaks

* don't mutate with lodash

* fix description re-rendering for nullable object fields

* hide reindex field

* update rjsf

* add frigate+ description to settings pane

* disable save all when any section is invalid

* show translated field name in validation error pane

* clean up

* remove unused

* fix genai merge

* fix genai
This commit is contained in:
Josh Hawkins
2026-02-27 08:55:36 -07:00
committed by GitHub
parent eeefbf2bb5
commit e7250f24cb
206 changed files with 22200 additions and 4435 deletions
@@ -0,0 +1,56 @@
import { ReactNode } from "react";
import { Label } from "../ui/label";
export const SPLIT_ROW_CLASS_NAME =
"space-y-2 md:grid md:grid-cols-[minmax(14rem,24rem)_minmax(0,1fr)] md:items-start md:gap-x-6 md:space-y-0";
export const DESCRIPTION_CLASS_NAME = "text-sm text-muted-foreground";
export const CONTROL_COLUMN_CLASS_NAME = "w-full md:max-w-2xl";
type SettingsGroupCardProps = {
title: string | ReactNode;
children: ReactNode;
};
export function SettingsGroupCard({ title, children }: SettingsGroupCardProps) {
return (
<div className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4">
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
{title}
</div>
{children}
</div>
);
}
type SplitCardRowProps = {
label: ReactNode;
description?: ReactNode;
content: ReactNode;
};
export function SplitCardRow({
label,
description,
content,
}: SplitCardRowProps) {
return (
<div className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<Label className="text-sm font-medium">{label}</Label>
{description && (
<div className={`hidden md:block ${DESCRIPTION_CLASS_NAME}`}>
{description}
</div>
)}
</div>
<div className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}>
{content}
{description && (
<div className={`md:hidden ${DESCRIPTION_CLASS_NAME}`}>
{description}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,370 @@
// ConfigForm - Main RJSF form wrapper component
import Form from "@rjsf/shadcn";
import validator from "@rjsf/validator-ajv8";
import type { FormValidation, RJSFSchema, UiSchema } from "@rjsf/utils";
import type { IChangeEvent } from "@rjsf/core";
import { frigateTheme } from "./theme";
import { transformSchema } from "@/lib/config-schema";
import { createErrorTransformer } from "@/lib/config-schema/errorMessages";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { cn, mergeUiSchema } from "@/lib/utils";
import type { ConfigFormContext } from "@/types/configForm";
type SchemaWithProperties = RJSFSchema & {
properties: Record<string, RJSFSchema>;
};
type SchemaWithAdditionalProperties = RJSFSchema & {
additionalProperties: RJSFSchema;
};
// Runtime guards for schema fragments
const hasSchemaProperties = (
schema: RJSFSchema,
): schema is SchemaWithProperties =>
typeof schema === "object" &&
schema !== null &&
typeof schema.properties === "object" &&
schema.properties !== null;
const hasSchemaAdditionalProperties = (
schema: RJSFSchema,
): schema is SchemaWithAdditionalProperties =>
typeof schema === "object" &&
schema !== null &&
typeof schema.additionalProperties === "object" &&
schema.additionalProperties !== null;
// Detects path-style uiSchema keys (e.g., "filters.*.mask")
const isPathKey = (key: string) => key.includes(".") || key.includes("*");
type UiSchemaPathOverride = {
path: string[];
value: UiSchema;
};
// Split uiSchema into normal keys vs path-based overrides
const splitUiSchemaOverrides = (
uiSchema?: UiSchema,
): { baseUiSchema?: UiSchema; pathOverrides: UiSchemaPathOverride[] } => {
if (!uiSchema) {
return { baseUiSchema: undefined, pathOverrides: [] };
}
const baseUiSchema: UiSchema = {};
const pathOverrides: UiSchemaPathOverride[] = [];
Object.entries(uiSchema).forEach(([key, value]) => {
if (isPathKey(key)) {
pathOverrides.push({
path: key.split("."),
value: value as UiSchema,
});
} else {
baseUiSchema[key] = value as UiSchema;
}
});
return { baseUiSchema, pathOverrides };
};
// Apply wildcard path overrides to uiSchema using the schema structure
const applyUiSchemaPathOverrides = (
uiSchema: UiSchema,
schema: RJSFSchema,
overrides: UiSchemaPathOverride[],
): UiSchema => {
if (overrides.length === 0) {
return uiSchema;
}
// Recursively apply a path override; supports "*" to match any property.
const applyOverride = (
targetUi: UiSchema,
targetSchema: RJSFSchema,
path: string[],
value: UiSchema,
) => {
if (path.length === 0) {
Object.assign(targetUi, mergeUiSchema(targetUi, value));
return;
}
const [segment, ...rest] = path;
const schemaObj = targetSchema;
if (segment === "*") {
if (hasSchemaProperties(schemaObj)) {
Object.entries(schemaObj.properties).forEach(
([propertyName, propertySchema]) => {
const existing =
(targetUi[propertyName] as UiSchema | undefined) || {};
targetUi[propertyName] = { ...existing };
applyOverride(
targetUi[propertyName] as UiSchema,
propertySchema,
rest,
value,
);
},
);
} else if (hasSchemaAdditionalProperties(schemaObj)) {
// For dict schemas, apply override to additionalProperties
const existing =
(targetUi.additionalProperties as UiSchema | undefined) || {};
targetUi.additionalProperties = { ...existing };
applyOverride(
targetUi.additionalProperties as UiSchema,
schemaObj.additionalProperties,
rest,
value,
);
}
return;
}
if (hasSchemaProperties(schemaObj)) {
const propertySchema = schemaObj.properties[segment];
if (propertySchema) {
const existing = (targetUi[segment] as UiSchema | undefined) || {};
targetUi[segment] = { ...existing };
applyOverride(
targetUi[segment] as UiSchema,
propertySchema,
rest,
value,
);
}
}
};
const updated = { ...uiSchema };
overrides.forEach(({ path, value }) => {
applyOverride(updated, schema, path, value);
});
return updated;
};
const applyLayoutGridFieldDefaults = (uiSchema: UiSchema): UiSchema => {
const applyDefaults = (node: unknown): unknown => {
if (Array.isArray(node)) {
return node.map((item) => applyDefaults(item));
}
if (typeof node !== "object" || node === null) {
return node;
}
const nextNode: Record<string, unknown> = {};
Object.entries(node).forEach(([key, value]) => {
nextNode[key] = applyDefaults(value);
});
if (
Array.isArray(nextNode["ui:layoutGrid"]) &&
nextNode["ui:field"] === undefined
) {
nextNode["ui:field"] = "LayoutGridField";
}
return nextNode;
};
return applyDefaults(uiSchema) as UiSchema;
};
export interface ConfigFormProps {
/** JSON Schema for the form */
schema: RJSFSchema;
/** Current form data */
formData?: unknown;
/** Called when form data changes */
onChange?: (data: unknown) => void;
/** Called when form is submitted */
onSubmit?: (data: unknown) => void;
/** Called when form has errors on submit */
onError?: (errors: unknown[]) => void;
/** Additional uiSchema overrides */
uiSchema?: UiSchema;
/** Field ordering */
fieldOrder?: string[];
/** Field groups for layout */
fieldGroups?: Record<string, string[]>;
/** Fields to hide */
hiddenFields?: string[];
/** Fields marked as advanced (collapsed by default) */
advancedFields?: string[];
/** Whether form is disabled */
disabled?: boolean;
/** Whether form is read-only */
readonly?: boolean;
/** Whether to show submit button */
showSubmit?: boolean;
/** Custom class name */
className?: string;
/** Live validation mode */
liveValidate?: boolean;
/** Form context passed to all widgets */
formContext?: ConfigFormContext;
/** i18n namespace for field labels */
i18nNamespace?: string;
/** Optional custom validation */
customValidate?: (
formData: unknown,
errors: FormValidation,
) => FormValidation;
/** Called whenever form validation state changes */
onValidationChange?: (hasErrors: boolean) => void;
}
export function ConfigForm({
schema,
formData,
onChange,
onSubmit,
onError,
uiSchema: customUiSchema,
fieldOrder,
fieldGroups,
hiddenFields,
advancedFields,
disabled = false,
readonly = false,
showSubmit = false,
className,
liveValidate = true,
formContext,
i18nNamespace,
customValidate,
onValidationChange,
}: ConfigFormProps) {
const { t, i18n } = useTranslation([
i18nNamespace || "common",
"views/settings",
"config/validation",
]);
// Determine which fields to hide based on advanced toggle
const effectiveHiddenFields = useMemo(() => {
return hiddenFields;
}, [hiddenFields]);
// Transform schema and generate uiSchema
const { schema: transformedSchema, uiSchema: generatedUiSchema } = useMemo(
() =>
transformSchema(schema, {
fieldOrder,
hiddenFields: effectiveHiddenFields,
advancedFields: advancedFields,
i18nNamespace,
}),
[schema, fieldOrder, effectiveHiddenFields, advancedFields, i18nNamespace],
);
const { baseUiSchema, pathOverrides } = useMemo(
() => splitUiSchemaOverrides(customUiSchema),
[customUiSchema],
);
// Merge generated uiSchema with custom overrides
const finalUiSchema = useMemo(() => {
// Start with generated schema
const expandedUiSchema = applyUiSchemaPathOverrides(
generatedUiSchema,
transformedSchema,
pathOverrides,
);
const merged = applyLayoutGridFieldDefaults(
mergeUiSchema(expandedUiSchema, baseUiSchema),
);
// Add field groups
if (fieldGroups) {
merged["ui:groups"] = fieldGroups;
}
// Set submit button options
merged["ui:submitButtonOptions"] = showSubmit
? { norender: false }
: { norender: true };
// Ensure hiddenFields take precedence over any custom uiSchema overrides
// Build path-based overrides for hidden fields and apply them after merging
if (hiddenFields && hiddenFields.length > 0) {
const hiddenOverrides = hiddenFields.map((field) => ({
path: field.split("."),
value: { "ui:widget": "hidden" } as UiSchema,
}));
return applyUiSchemaPathOverrides(
merged,
transformedSchema,
hiddenOverrides,
);
}
return merged;
}, [
generatedUiSchema,
transformedSchema,
pathOverrides,
baseUiSchema,
showSubmit,
fieldGroups,
hiddenFields,
]);
// Create error transformer for user-friendly error messages
const errorTransformer = useMemo(() => createErrorTransformer(i18n), [i18n]);
const handleChange = useCallback(
(e: IChangeEvent) => {
onValidationChange?.(Array.isArray(e.errors) && e.errors.length > 0);
onChange?.(e.formData);
},
[onChange, onValidationChange],
);
const handleSubmit = useCallback(
(e: IChangeEvent) => {
onSubmit?.(e.formData);
},
[onSubmit],
);
// Extended form context with i18n info
const extendedFormContext = useMemo(
() => ({
...formContext,
i18nNamespace,
t,
}),
[formContext, i18nNamespace, t],
);
return (
<div className={cn("config-form w-full max-w-5xl", className)}>
<Form
schema={transformedSchema}
uiSchema={finalUiSchema}
formData={formData}
validator={validator}
onChange={handleChange}
onSubmit={handleSubmit}
onError={onError}
disabled={disabled}
readonly={readonly}
liveValidate={liveValidate}
formContext={extendedFormContext}
transformErrors={errorTransformer}
customValidate={customValidate}
{...frigateTheme}
/>
</div>
);
}
export default ConfigForm;
@@ -0,0 +1,42 @@
import type { SectionConfigOverrides } from "./types";
const audio: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/audio_detectors",
restartRequired: [],
fieldOrder: [
"enabled",
"listen",
"filters",
"min_volume",
"max_not_heard",
"num_threads",
],
fieldGroups: {
detection: ["enabled", "listen", "filters"],
sensitivity: ["min_volume", "max_not_heard"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["min_volume", "max_not_heard", "num_threads"],
uiSchema: {
listen: {
"ui:widget": "audioLabels",
},
},
},
global: {
restartRequired: [
"enabled",
"listen",
"filters",
"min_volume",
"max_not_heard",
"num_threads",
],
},
camera: {
restartRequired: ["num_threads"],
},
};
export default audio;
@@ -0,0 +1,19 @@
import type { SectionConfigOverrides } from "./types";
const audioTranscription: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/audio_detectors#audio-transcription",
restartRequired: [],
fieldOrder: ["enabled", "language", "device", "model_size"],
hiddenFields: ["enabled_in_config", "live_enabled"],
advancedFields: ["language", "device", "model_size"],
overrideFields: ["enabled", "live_enabled"],
},
global: {
fieldOrder: ["enabled", "language", "device", "model_size"],
advancedFields: ["language", "device", "model_size"],
restartRequired: ["enabled", "language", "device", "model_size"],
},
};
export default audioTranscription;
@@ -0,0 +1,49 @@
import type { SectionConfigOverrides } from "./types";
const auth: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/authentication",
restartRequired: [
"enabled",
"reset_admin_password",
"failed_login_rate_limit",
],
fieldOrder: [
"enabled",
"reset_admin_password",
"cookie_name",
"cookie_secure",
"session_length",
"refresh_time",
"native_oauth_url",
"failed_login_rate_limit",
"trusted_proxies",
"hash_iterations",
"roles",
],
hiddenFields: ["admin_first_time_login", "roles"],
advancedFields: [
"cookie_name",
"cookie_secure",
"session_length",
"refresh_time",
"failed_login_rate_limit",
"trusted_proxies",
"hash_iterations",
"roles",
],
uiSchema: {
reset_admin_password: {
"ui:widget": "switch",
},
native_oauth_url: {
"ui:options": { size: "lg" },
},
failed_login_rate_limit: {
"ui:options": { size: "md" },
},
},
},
};
export default auth;
@@ -0,0 +1,45 @@
import type { SectionConfigOverrides } from "./types";
const birdseye: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/birdseye",
restartRequired: [],
fieldOrder: ["enabled", "mode", "order"],
hiddenFields: [],
advancedFields: [],
overrideFields: ["enabled", "mode"],
},
global: {
fieldOrder: [
"enabled",
"restream",
"width",
"height",
"quality",
"mode",
"layout",
"inactivity_threshold",
"idle_heartbeat_fps",
],
advancedFields: ["width", "height", "quality", "inactivity_threshold"],
restartRequired: [
"enabled",
"restream",
"width",
"height",
"quality",
"mode",
"layout.scaling_factor",
"inactivity_threshold",
"layout.max_cameras",
"idle_heartbeat_fps",
],
uiSchema: {
mode: {
"ui:size": "xs",
},
},
},
};
export default birdseye;
@@ -0,0 +1,12 @@
import type { SectionConfigOverrides } from "./types";
const classification: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/custom_classification/object_classification",
restartRequired: ["bird.enabled", "bird.threshold"],
hiddenFields: ["custom"],
advancedFields: [],
},
};
export default classification;
@@ -0,0 +1,17 @@
import type { SectionConfigOverrides } from "./types";
const database: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/advanced#database",
restartRequired: ["path"],
fieldOrder: ["path"],
advancedFields: [],
uiSchema: {
path: {
"ui:options": { size: "md" },
},
},
},
};
export default database;
@@ -0,0 +1,49 @@
import type { SectionConfigOverrides } from "./types";
const detect: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/camera_specific",
fieldOrder: [
"enabled",
"width",
"height",
"fps",
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
"interval",
"threshold",
"max_frames",
],
restartRequired: [],
fieldGroups: {
resolution: ["enabled", "width", "height", "fps"],
tracking: ["min_initialized", "max_disappeared"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: [
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
],
},
global: {
restartRequired: [
"enabled",
"width",
"height",
"fps",
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
],
},
camera: {
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
},
};
export default detect;
@@ -0,0 +1,28 @@
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"],
typeOrder: ["onnx", "openvino", "edgetpu"],
hiddenByType: {},
hiddenFields: detectorHiddenFields,
},
},
},
};
export default detectors;
@@ -0,0 +1,16 @@
import type { SectionConfigOverrides } from "./types";
const environmentVars: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/advanced#environment_vars",
fieldOrder: [],
advancedFields: [],
uiSchema: {
additionalProperties: {
"ui:options": { size: "lg" },
},
},
},
};
export default environmentVars;
@@ -0,0 +1,50 @@
import type { SectionConfigOverrides } from "./types";
const faceRecognition: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/face_recognition",
restartRequired: [],
fieldOrder: ["enabled", "min_area"],
hiddenFields: [],
advancedFields: ["min_area"],
overrideFields: ["enabled", "min_area"],
},
global: {
fieldOrder: [
"enabled",
"model_size",
"unknown_score",
"detection_threshold",
"recognition_threshold",
"min_area",
"min_faces",
"save_attempts",
"blur_confidence_filter",
"device",
],
advancedFields: [
"unknown_score",
"detection_threshold",
"recognition_threshold",
"min_area",
"min_faces",
"save_attempts",
"blur_confidence_filter",
"device",
],
restartRequired: [
"enabled",
"model_size",
"unknown_score",
"detection_threshold",
"recognition_threshold",
"min_area",
"min_faces",
"save_attempts",
"blur_confidence_filter",
"device",
],
},
};
export default faceRecognition;
@@ -0,0 +1,179 @@
import type { SectionConfigOverrides } from "./types";
const arrayAsTextWidget = {
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
suppressMultiSchema: true,
},
};
const ffmpegArgsWidget = (
presetField: string,
extraOptions?: Record<string, unknown>,
) => ({
"ui:widget": "FfmpegArgsWidget",
"ui:options": {
suppressMultiSchema: true,
ffmpegPresetField: presetField,
...extraOptions,
},
});
const ffmpeg: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/ffmpeg_presets",
fieldDocs: {
hwaccel_args: "/configuration/ffmpeg_presets#hwaccel-presets",
"inputs.hwaccel_args": "/configuration/ffmpeg_presets#hwaccel-presets",
input_args: "/configuration/ffmpeg_presets#input-args-presets",
"inputs.input_args": "/configuration/ffmpeg_presets#input-args-presets",
output_args: "/configuration/ffmpeg_presets#output-args-presets",
"inputs.output_args": "/configuration/ffmpeg_presets#output-args-presets",
"output_args.record": "/configuration/ffmpeg_presets#output-args-presets",
"inputs.roles": "/configuration/cameras/#setting-up-camera-inputs",
},
restartRequired: [],
fieldOrder: [
"inputs",
"global_args",
"input_args",
"hwaccel_args",
"output_args",
"path",
"retry_interval",
"apple_compatibility",
"gpu",
],
hiddenFields: [],
advancedFields: [
"path",
"global_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
overrideFields: [
"inputs",
"path",
"global_args",
"input_args",
"hwaccel_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
uiSchema: {
path: {
"ui:options": { size: "md" },
},
global_args: arrayAsTextWidget,
hwaccel_args: ffmpegArgsWidget("hwaccel_args"),
input_args: ffmpegArgsWidget("input_args"),
output_args: {
detect: arrayAsTextWidget,
record: ffmpegArgsWidget("output_args.record"),
items: {
detect: arrayAsTextWidget,
record: ffmpegArgsWidget("output_args.record"),
},
},
inputs: {
"ui:field": "CameraInputsField",
items: {
path: {
"ui:options": { size: "full" },
},
roles: {
"ui:widget": "inputRoles",
"ui:options": {
showArrayItemDescription: true,
},
},
global_args: {
"ui:widget": "hidden",
},
hwaccel_args: ffmpegArgsWidget("hwaccel_args", {
allowInherit: true,
hideDescription: true,
forceSplitLayout: true,
showArrayItemDescription: true,
}),
input_args: ffmpegArgsWidget("input_args", {
allowInherit: true,
hideDescription: true,
forceSplitLayout: true,
showArrayItemDescription: true,
}),
output_args: {
items: {
detect: arrayAsTextWidget,
record: ffmpegArgsWidget("output_args.record"),
},
},
},
},
},
},
global: {
restartRequired: [
"path",
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
fieldOrder: [
"hwaccel_args",
"path",
"global_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
advancedFields: [
"global_args",
"input_args",
"output_args",
"path",
"retry_interval",
"apple_compatibility",
"gpu",
],
uiSchema: {
path: {
"ui:options": { size: "md" },
},
global_args: arrayAsTextWidget,
hwaccel_args: ffmpegArgsWidget("hwaccel_args"),
input_args: ffmpegArgsWidget("input_args"),
output_args: {
detect: arrayAsTextWidget,
record: ffmpegArgsWidget("output_args.record"),
},
},
},
camera: {
fieldGroups: {
cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
},
restartRequired: [
"inputs",
"path",
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
},
};
export default ffmpeg;
@@ -0,0 +1,48 @@
import type { SectionConfigOverrides } from "./types";
const genai: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/genai/config",
restartRequired: [
"provider",
"api_key",
"base_url",
"model",
"provider_options",
"runtime_options",
],
fieldOrder: [
"provider",
"api_key",
"base_url",
"model",
"provider_options",
"runtime_options",
],
advancedFields: ["base_url", "provider_options", "runtime_options"],
hiddenFields: ["genai.enabled_in_config"],
uiSchema: {
api_key: {
"ui:options": { size: "md" },
},
base_url: {
"ui:options": { size: "lg" },
},
model: {
"ui:options": { size: "md" },
},
provider_options: {
additionalProperties: {
"ui:options": { size: "lg" },
},
},
runtime_options: {
additionalProperties: {
"ui:options": { size: "lg" },
},
},
},
},
};
export default genai;
@@ -0,0 +1,21 @@
import type { SectionConfigOverrides } from "./types";
const live: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/live",
restartRequired: [],
fieldOrder: ["stream_name", "height", "quality"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: ["height", "quality"],
},
global: {
restartRequired: ["stream_name", "height", "quality"],
hiddenFields: ["streams"],
},
camera: {
restartRequired: ["height", "quality"],
},
};
export default live;
@@ -0,0 +1,12 @@
import type { SectionConfigOverrides } from "./types";
const logger: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/advanced#logger",
restartRequired: ["default", "logs"],
fieldOrder: ["default", "logs"],
advancedFields: ["logs"],
},
};
export default logger;
@@ -0,0 +1,73 @@
import type { SectionConfigOverrides } from "./types";
const lpr: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/license_plate_recognition",
fieldDocs: {
enhancement: "/configuration/license_plate_recognition#enhancement",
},
restartRequired: [],
fieldOrder: ["enabled", "expire_time", "min_area", "enhancement"],
hiddenFields: [],
advancedFields: ["expire_time", "min_area", "enhancement"],
overrideFields: ["enabled", "min_area", "enhancement"],
},
global: {
fieldOrder: [
"enabled",
"model_size",
"detection_threshold",
"min_area",
"recognition_threshold",
"min_plate_length",
"format",
"match_distance",
"known_plates",
"enhancement",
"debug_save_plates",
"device",
"replace_rules",
],
advancedFields: [
"detection_threshold",
"recognition_threshold",
"min_plate_length",
"format",
"match_distance",
"known_plates",
"enhancement",
"debug_save_plates",
"device",
"replace_rules",
],
restartRequired: [
"enabled",
"model_size",
"detection_threshold",
"min_area",
"recognition_threshold",
"min_plate_length",
"format",
"match_distance",
"known_plates",
"enhancement",
"debug_save_plates",
"device",
"replace_rules",
],
uiSchema: {
format: {
"ui:options": { size: "md" },
},
replace_rules: {
"ui:field": "ReplaceRulesField",
"ui:options": {
label: false,
suppressDescription: true,
},
},
},
},
};
export default lpr;
@@ -0,0 +1,53 @@
import type { SectionConfigOverrides } from "./types";
const model: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/object_detectors#model",
restartRequired: [
"path",
"labelmap_path",
"width",
"height",
"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",
],
hiddenFields: [
"labelmap",
"attributes_map",
"colormap",
"all_attributes",
"non_logo_attributes",
"plus",
],
uiSchema: {
path: {
"ui:options": { size: "md" },
},
labelmap_path: {
"ui:options": { size: "md" },
},
},
},
};
export default model;
@@ -0,0 +1,49 @@
import type { SectionConfigOverrides } from "./types";
const motion: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/motion_detection",
restartRequired: [],
fieldOrder: [
"enabled",
"threshold",
"lightning_threshold",
"improve_contrast",
"contour_area",
"delta_alpha",
"frame_alpha",
"frame_height",
"mqtt_off_delay",
],
fieldGroups: {
sensitivity: ["enabled", "threshold", "contour_area"],
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
},
hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
advancedFields: [
"lightning_threshold",
"delta_alpha",
"frame_alpha",
"frame_height",
"mqtt_off_delay",
],
},
global: {
restartRequired: [
"enabled",
"threshold",
"lightning_threshold",
"improve_contrast",
"contour_area",
"delta_alpha",
"frame_alpha",
"frame_height",
"mqtt_off_delay",
],
},
camera: {
restartRequired: ["frame_height"],
},
};
export default motion;
@@ -0,0 +1,73 @@
import type { SectionConfigOverrides } from "./types";
const mqtt: SectionConfigOverrides = {
base: {
sectionDocs: "/integrations/mqtt",
restartRequired: [],
fieldOrder: [
"enabled",
"timestamp",
"bounding_box",
"crop",
"height",
"required_zones",
"quality",
],
hiddenFields: [],
advancedFields: ["height", "quality"],
overrideFields: [],
uiSchema: {
required_zones: {
"ui:widget": "zoneNames",
},
},
},
global: {
fieldOrder: [
"enabled",
"host",
"port",
"user",
"password",
"topic_prefix",
"client_id",
"stats_interval",
"qos",
"tls_ca_certs",
"tls_client_cert",
"tls_client_key",
"tls_insecure",
],
advancedFields: [
"stats_interval",
"qos",
"tls_ca_certs",
"tls_client_cert",
"tls_client_key",
"tls_insecure",
],
restartRequired: [
"enabled",
"host",
"port",
"user",
"password",
"topic_prefix",
"client_id",
"stats_interval",
"qos",
"tls_ca_certs",
"tls_client_cert",
"tls_client_key",
"tls_insecure",
],
liveValidate: true,
uiSchema: {
password: {
"ui:options": { size: "xs" },
},
},
},
};
export default mqtt;
@@ -0,0 +1,30 @@
import type { SectionConfigOverrides } from "./types";
const networking: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/advanced",
fieldDocs: {
"listen.internal": "/configuration/advanced#listen-on-different-ports",
"listen.external": "/configuration/advanced#listen-on-different-ports",
},
restartRequired: ["ipv6.enabled", "listen.internal", "listen.external"],
fieldOrder: [],
advancedFields: [],
uiSchema: {
"listen.internal": {
"ui:options": {
suppressMultiSchema: true,
size: "sm",
},
},
"listen.external": {
"ui:options": {
suppressMultiSchema: true,
size: "sm",
},
},
},
},
};
export default networking;
@@ -0,0 +1,26 @@
import type { SectionConfigOverrides } from "./types";
const notifications: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/notifications",
restartRequired: [],
fieldOrder: ["enabled", "email"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: [],
},
global: {
uiSchema: {
"ui:before": { render: "NotificationsSettingsExtras" },
enabled: { "ui:widget": "hidden" },
email: { "ui:widget": "hidden" },
cooldown: { "ui:widget": "hidden" },
enabled_in_config: { "ui:widget": "hidden" },
},
},
camera: {
hiddenFields: ["enabled_in_config", "email"],
},
};
export default notifications;
@@ -0,0 +1,104 @@
import type { SectionConfigOverrides } from "./types";
const objects: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/object_filters",
fieldDocs: {
"filters.min_area": "/configuration/object_filters#object-area",
"filters.max_area": "/configuration/object_filters#object-area",
"filters.min_score": "/configuration/object_filters#minimum-score",
"filters.threshold": "/configuration/object_filters#threshold",
"filters.min_ratio": "/configuration/object_filters/#object-proportions",
"filters.max_ratio": "/configuration/object_filters/#object-proportions",
},
restartRequired: [],
fieldOrder: ["track", "alert", "detect", "filters"],
fieldGroups: {
tracking: ["track", "alert", "detect"],
filtering: ["filters"],
},
hiddenFields: [
"enabled_in_config",
"mask",
"raw_mask",
"genai.enabled_in_config",
"filters.*.mask",
"filters.*.raw_mask",
"filters.mask",
"filters.raw_mask",
],
advancedFields: ["genai"],
uiSchema: {
"filters.*.min_area": {
"ui:options": {
suppressMultiSchema: true,
},
},
"filters.*": {
"ui:options": {
additionalPropertyKeyReadonly: true,
},
},
"filters.*.max_area": {
"ui:options": {
suppressMultiSchema: true,
},
},
track: {
"ui:widget": "objectLabels",
"ui:options": {
suppressMultiSchema: true,
},
},
genai: {
objects: {
"ui:widget": "objectLabels",
"ui:options": {
suppressMultiSchema: true,
},
},
prompt: {
"ui:widget": "textarea",
"ui:options": {
size: "full",
},
},
object_prompts: {
additionalProperties: {
"ui:options": {
size: "full",
},
},
},
required_zones: {
"ui:widget": "zoneNames",
"ui:options": {
suppressMultiSchema: true,
},
},
enabled_in_config: {
"ui:widget": "hidden",
},
},
},
},
global: {
restartRequired: ["track", "alert", "detect", "filters", "genai"],
hiddenFields: [
"enabled_in_config",
"mask",
"raw_mask",
"genai.enabled_in_config",
"filters.*.mask",
"filters.*.raw_mask",
"filters.mask",
"filters.raw_mask",
"genai.required_zones",
],
},
camera: {
restartRequired: [],
},
};
export default objects;
@@ -0,0 +1,46 @@
import type { SectionConfigOverrides } from "./types";
const onvif: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/cameras#setting-up-camera-ptz-controls",
restartRequired: [
"host",
"port",
"user",
"password",
"tls_insecure",
"ignore_time_mismatch",
"autotracking.calibrate_on_startup",
],
fieldOrder: [
"host",
"port",
"user",
"password",
"tls_insecure",
"ignore_time_mismatch",
"autotracking",
],
hiddenFields: [
"autotracking.enabled_in_config",
"autotracking.movement_weights",
],
advancedFields: ["tls_insecure", "ignore_time_mismatch"],
overrideFields: [],
uiSchema: {
host: {
"ui:options": { size: "sm" },
},
autotracking: {
required_zones: {
"ui:widget": "zoneNames",
},
track: {
"ui:widget": "objectLabels",
},
},
},
},
};
export default onvif;
@@ -0,0 +1,33 @@
import type { SectionConfigOverrides } from "./types";
const proxy: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/authentication#proxy",
restartRequired: [],
fieldOrder: [
"header_map",
"logout_url",
"auth_secret",
"default_role",
"separator",
],
advancedFields: ["header_map", "auth_secret", "separator"],
liveValidate: true,
uiSchema: {
logout_url: {
"ui:options": { size: "lg" },
},
auth_secret: {
"ui:options": { size: "md" },
},
header_map: {
"ui:after": { render: "ProxyRoleMap" },
},
"header_map.role_map": {
"ui:widget": "hidden",
},
},
},
};
export default proxy;
@@ -0,0 +1,48 @@
import type { SectionConfigOverrides } from "./types";
const record: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/record",
restartRequired: [],
fieldOrder: [
"enabled",
"expire_interval",
"continuous",
"motion",
"alerts",
"detections",
"preview",
"export",
],
fieldGroups: {
retention: ["enabled", "continuous", "motion"],
events: ["alerts", "detections"],
},
hiddenFields: ["enabled_in_config", "sync_recordings"],
advancedFields: ["expire_interval", "preview", "export"],
uiSchema: {
export: {
hwaccel_args: {
"ui:options": { size: "lg" },
},
},
},
},
global: {
restartRequired: [
"enabled",
"expire_interval",
"continuous",
"motion",
"alerts",
"detections",
"preview",
"export",
],
},
camera: {
restartRequired: [],
},
};
export default record;
@@ -0,0 +1,54 @@
import type { SectionConfigOverrides } from "./types";
const review: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/review",
restartRequired: [],
fieldOrder: ["alerts", "detections", "genai"],
fieldGroups: {},
hiddenFields: [
"enabled_in_config",
"alerts.labels",
"alerts.enabled_in_config",
"detections.labels",
"detections.enabled_in_config",
"genai.enabled_in_config",
],
advancedFields: [],
uiSchema: {
alerts: {
"ui:before": { render: "CameraReviewStatusToggles" },
required_zones: {
"ui:widget": "hidden",
},
},
detections: {
required_zones: {
"ui:widget": "hidden",
},
},
genai: {
additional_concerns: {
"ui:widget": "textarea",
"ui:options": {
size: "full",
},
},
activity_context_prompt: {
"ui:widget": "textarea",
"ui:options": {
size: "full",
},
},
},
},
},
global: {
restartRequired: ["alerts", "detections", "genai"],
},
camera: {
restartRequired: [],
},
};
export default review;
@@ -0,0 +1,24 @@
import type { SectionConfigOverrides } from "./types";
const semanticSearch: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/semantic_search",
restartRequired: [],
hiddenFields: [],
advancedFields: [],
overrideFields: [],
uiSchema: {
enabled: {
"ui:after": { render: "SemanticSearchReindex" },
},
},
},
global: {
fieldOrder: ["enabled", "reindex", "model", "model_size", "device"],
advancedFields: ["reindex", "device"],
restartRequired: ["enabled", "model", "model_size", "device"],
hiddenFields: ["reindex"],
},
};
export default semanticSearch;
@@ -0,0 +1,45 @@
import type { SectionConfigOverrides } from "./types";
const snapshots: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/snapshots",
restartRequired: [],
fieldOrder: [
"enabled",
"bounding_box",
"crop",
"quality",
"timestamp",
"retain",
],
fieldGroups: {
display: ["enabled", "bounding_box", "crop", "quality", "timestamp"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["height", "quality", "retain"],
uiSchema: {
required_zones: {
"ui:widget": "zoneNames",
"ui:options": {
suppressMultiSchema: true,
},
},
},
},
global: {
restartRequired: [
"enabled",
"bounding_box",
"crop",
"quality",
"timestamp",
"retain",
],
hiddenFields: ["enabled_in_config", "required_zones"],
},
camera: {
restartRequired: [],
},
};
export default snapshots;
@@ -0,0 +1,19 @@
import type { SectionConfigOverrides } from "./types";
const telemetry: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/reference",
restartRequired: [
"network_interfaces",
"stats.amd_gpu_stats",
"stats.intel_gpu_stats",
"stats.intel_gpu_device",
"stats.network_bandwidth",
"version_check",
],
fieldOrder: ["network_interfaces", "stats", "version_check"],
advancedFields: [],
},
};
export default telemetry;
@@ -0,0 +1,27 @@
import type { SectionConfigOverrides } from "./types";
const timestampStyle: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/reference",
restartRequired: [],
fieldOrder: ["position", "format", "color", "thickness"],
hiddenFields: ["effect", "enabled_in_config"],
advancedFields: [],
uiSchema: {
position: {
"ui:size": "xs",
},
format: {
"ui:size": "xs",
},
},
},
global: {
restartRequired: ["position", "format", "color", "thickness", "effect"],
},
camera: {
restartRequired: [],
},
};
export default timestampStyle;
@@ -0,0 +1,20 @@
import type { SectionConfigOverrides } from "./types";
const tls: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/tls",
restartRequired: ["enabled"],
fieldOrder: ["enabled", "cert", "key"],
advancedFields: [],
uiSchema: {
cert: {
"ui:options": { size: "md" },
},
key: {
"ui:options": { size: "md" },
},
},
},
};
export default tls;
@@ -0,0 +1,7 @@
import type { SectionConfig } from "../sections/BaseSection";
export type SectionConfigOverrides = {
base?: SectionConfig;
global?: Partial<SectionConfig>;
camera?: Partial<SectionConfig>;
};
@@ -0,0 +1,30 @@
import type { SectionConfigOverrides } from "./types";
const ui: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/reference",
restartRequired: [],
fieldOrder: ["dashboard", "order"],
hiddenFields: [],
advancedFields: [],
overrideFields: [],
},
global: {
fieldOrder: [
"timezone",
"time_format",
"date_style",
"time_style",
"unit_system",
],
advancedFields: [],
restartRequired: ["unit_system"],
uiSchema: {
timezone: {
"ui:widget": "timezoneSelect",
},
},
},
};
export default ui;
@@ -0,0 +1,84 @@
import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next";
import { isJsonObject } from "@/lib/utils";
import type { JsonObject } from "@/types/configForm";
function hasValue(value: unknown): boolean {
if (value === null || value === undefined || value === "") {
return false;
}
if (Array.isArray(value)) {
return value.length > 0;
}
return true;
}
export function validateFfmpegInputRoles(
formData: unknown,
errors: FormValidation,
t: TFunction,
): FormValidation {
if (!isJsonObject(formData as JsonObject)) {
return errors;
}
const inputs = (formData as JsonObject).inputs;
if (!Array.isArray(inputs)) {
return errors;
}
const roleCounts = new Map<string, number>();
let hasDetect = false;
let hasInvalidHwaccel = false;
inputs.forEach((input) => {
if (!isJsonObject(input) || !Array.isArray(input.roles)) {
return;
}
input.roles.forEach((role) => {
if (typeof role !== "string") {
return;
}
roleCounts.set(role, (roleCounts.get(role) || 0) + 1);
});
if (input.roles.includes("detect")) {
hasDetect = true;
} else if (hasValue(input.hwaccel_args)) {
hasInvalidHwaccel = true;
}
});
const hasDuplicates = Array.from(roleCounts.values()).some(
(count) => count > 1,
);
if (hasDuplicates) {
const inputsErrors = errors.inputs as {
addError?: (message: string) => void;
};
inputsErrors?.addError?.(
t("ffmpeg.inputs.rolesUnique", { ns: "config/validation" }),
);
}
if (!hasDetect) {
const inputsErrors = errors.inputs as {
addError?: (message: string) => void;
};
inputsErrors?.addError?.(
t("ffmpeg.inputs.detectRequired", { ns: "config/validation" }),
);
}
if (hasInvalidHwaccel) {
const inputsErrors = errors.inputs as {
addError?: (message: string) => void;
};
inputsErrors?.addError?.(
t("ffmpeg.inputs.hwaccelDetectOnly", { ns: "config/validation" }),
);
}
return errors;
}
@@ -0,0 +1,31 @@
import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next";
import { validateFfmpegInputRoles } from "./ffmpeg";
import { validateProxyRoleHeader } from "./proxy";
export type SectionValidation = (
formData: unknown,
errors: FormValidation,
) => FormValidation;
type SectionValidationOptions = {
sectionPath: string;
level: "global" | "camera";
t: TFunction;
};
export function getSectionValidation({
sectionPath,
level,
t,
}: SectionValidationOptions): SectionValidation | undefined {
if (sectionPath === "ffmpeg" && level === "camera") {
return (formData, errors) => validateFfmpegInputRoles(formData, errors, t);
}
if (sectionPath === "proxy" && level === "global") {
return (formData, errors) => validateProxyRoleHeader(formData, errors, t);
}
return undefined;
}
@@ -0,0 +1,37 @@
import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next";
import { isJsonObject } from "@/lib/utils";
import type { JsonObject } from "@/types/configForm";
export function validateProxyRoleHeader(
formData: unknown,
errors: FormValidation,
t: TFunction,
): FormValidation {
if (!isJsonObject(formData as JsonObject)) {
return errors;
}
const headerMap = (formData as JsonObject).header_map;
if (!isJsonObject(headerMap)) {
return errors;
}
const roleHeader = headerMap.role;
const roleHeaderDefined =
typeof roleHeader === "string" && roleHeader.trim().length > 0;
const roleMap = headerMap.role_map;
const roleMapHasEntries =
isJsonObject(roleMap) && Object.keys(roleMap).length > 0;
if (roleMapHasEntries && !roleHeaderDefined) {
const headerMapErrors = errors.header_map as {
role?: { addError?: (message: string) => void };
};
headerMapErrors?.role?.addError?.(
t("proxy.header_map.roleHeaderRequired", { ns: "config/validation" }),
);
}
return errors;
}
@@ -0,0 +1,85 @@
/*
sectionConfigs.ts — section configuration overrides
Purpose:
- Centralize UI configuration hints for each config section (field ordering,
grouping, hidden/advanced fields, uiSchema overrides, and overrideFields).
Shape:
- Each section key maps to an object with optional `base`, `global`, and
`camera` entries where each is a `SectionConfig` (or partial):
{
base?: SectionConfig; // common defaults (typically camera-level)
global?: Partial<SectionConfig>; // overrides for global-level UI
camera?: Partial<SectionConfig>; // overrides for camera-level UI
}
*/
import type { SectionConfigOverrides } from "./section-configs/types";
import audio from "./section-configs/audio";
import audioTranscription from "./section-configs/audio_transcription";
import auth from "./section-configs/auth";
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";
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 motion from "./section-configs/motion";
import mqtt from "./section-configs/mqtt";
import networking from "./section-configs/networking";
import notifications from "./section-configs/notifications";
import objects from "./section-configs/objects";
import onvif from "./section-configs/onvif";
import proxy from "./section-configs/proxy";
import record from "./section-configs/record";
import review from "./section-configs/review";
import semanticSearch from "./section-configs/semantic_search";
import snapshots from "./section-configs/snapshots";
import telemetry from "./section-configs/telemetry";
import timestampStyle from "./section-configs/timestamp_style";
import tls from "./section-configs/tls";
import ui from "./section-configs/ui";
export const sectionConfigs: Record<string, SectionConfigOverrides> = {
detect,
record,
snapshots,
motion,
objects,
review,
audio,
live,
timestamp_style: timestampStyle,
notifications,
onvif,
ffmpeg,
audio_transcription: audioTranscription,
birdseye,
face_recognition: faceRecognition,
lpr,
semantic_search: semanticSearch,
mqtt,
ui,
database,
auth,
tls,
networking,
proxy,
logger,
environment_vars: environmentVars,
telemetry,
detectors,
model,
genai,
classification,
};
export type { SectionConfigOverrides } from "./section-configs/types";
@@ -0,0 +1,403 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import cloneDeep from "lodash/cloneDeep";
import get from "lodash/get";
import isEqual from "lodash/isEqual";
import set from "lodash/set";
import { LuExternalLink } from "react-icons/lu";
import { MdCircle } from "react-icons/md";
import Heading from "@/components/ui/heading";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { getTranslatedLabel } from "@/utils/i18n";
import { formatList } from "@/utils/stringUtil";
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
import type { SectionRendererProps } from "./registry";
const EMPTY_ZONES: string[] = [];
function getRequiredZones(
formData: JsonObject | undefined,
path: string,
): string[] {
const value = get(formData, path);
return Array.isArray(value) ? (value as string[]) : EMPTY_ZONES;
}
export default function CameraReviewClassification({
formContext,
selectedCamera,
}: SectionRendererProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { getLocaleDocUrl } = useDocDomain();
const cameraName = formContext?.cameraName ?? selectedCamera;
const fullFormData = formContext?.formData as JsonObject | undefined;
const baselineFormData = formContext?.baselineFormData as
| JsonObject
| undefined;
const cameraConfig = formContext?.fullCameraConfig;
const alertsZones = useMemo(
() => getRequiredZones(fullFormData, "alerts.required_zones"),
[fullFormData],
);
const detectionsZones = useMemo(
() => getRequiredZones(fullFormData, "detections.required_zones"),
[fullFormData],
);
// Track whether zones have been modified from baseline for label coloring
const alertsZonesModified = useMemo(() => {
if (!baselineFormData) return false;
const baseline = getRequiredZones(
baselineFormData,
"alerts.required_zones",
);
return !isEqual(alertsZones, baseline);
}, [alertsZones, baselineFormData]);
const detectionsZonesModified = useMemo(() => {
if (!baselineFormData) return false;
const baseline = getRequiredZones(
baselineFormData,
"detections.required_zones",
);
return !isEqual(detectionsZones, baseline);
}, [detectionsZones, baselineFormData]);
const [selectDetections, setSelectDetections] = useState(
detectionsZones.length > 0,
);
const previousCameraRef = useRef(cameraName);
const isSynced = formContext?.hasChanges === false;
useEffect(() => {
const cameraChanged = previousCameraRef.current !== cameraName;
if (cameraChanged) {
previousCameraRef.current = cameraName;
}
if (cameraChanged || isSynced) {
setSelectDetections(detectionsZones.length > 0);
}
}, [cameraName, detectionsZones.length, isSynced]);
const zones = useMemo(() => {
if (!cameraConfig) {
return undefined;
}
return Object.entries(cameraConfig.zones).map(([name, zoneData]) => {
const zone =
zoneData as (typeof cameraConfig.zones)[keyof typeof cameraConfig.zones];
return {
camera: cameraConfig.name,
name,
friendly_name: cameraConfig.zones[name].friendly_name,
objects: zone.objects,
color: zone.color,
};
});
}, [cameraConfig]);
const alertsLabels = useMemo(() => {
return cameraConfig?.review.alerts.labels
? formatList(
cameraConfig.review.alerts.labels.map((label: string) =>
getTranslatedLabel(
label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
),
),
)
: "";
}, [cameraConfig]);
const detectionsLabels = useMemo(() => {
return cameraConfig?.review.detections.labels
? formatList(
cameraConfig.review.detections.labels.map((label: string) =>
getTranslatedLabel(
label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
),
),
)
: "";
}, [cameraConfig]);
const selectCameraName = useCameraFriendlyName(cameraName);
const getZoneName = useCallback(
(zoneId: string, camId?: string) =>
resolveZoneName(formContext?.fullConfig, zoneId, camId),
[formContext?.fullConfig],
);
const updateFormData = useCallback(
(path: string, nextValue: string[]) => {
if (!formContext?.onFormDataChange || !fullFormData) {
return;
}
const nextData = cloneDeep(fullFormData) as JsonObject;
set(nextData, path, nextValue);
formContext.onFormDataChange(nextData as ConfigSectionData);
},
[formContext, fullFormData],
);
const handleZoneToggle = useCallback(
(path: string, zoneName: string) => {
const currentZones = getRequiredZones(fullFormData, path);
const nextZones = currentZones.includes(zoneName)
? currentZones.filter((value) => value !== zoneName)
: [...currentZones, zoneName];
updateFormData(path, nextZones);
},
[fullFormData, updateFormData],
);
const handleDetectionsToggle = useCallback(
(checked: boolean | string) => {
const isChecked = checked === true;
if (!isChecked) {
updateFormData("detections.required_zones", []);
}
setSelectDetections(isChecked);
},
[updateFormData],
);
if (!cameraName || formContext?.level !== "camera") {
return null;
}
return (
<div className="mb-4 space-y-6">
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.reviewClassification.title
</Trans>
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
<Trans ns="views/settings">
cameraReview.reviewClassification.desc
</Trans>
</p>
<div className="flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/review")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<div
className={cn(
"w-full max-w-5xl space-y-0",
zones && zones.length > 0 && "grid items-start gap-5 md:grid-cols-2",
)}
>
<div>
{zones && zones.length > 0 ? (
<>
<div className="mb-2">
<Label
className={cn(
"flex flex-row items-center text-base",
alertsZonesModified && "text-danger",
)}
>
<Trans ns="views/settings">cameraReview.review.alerts</Trans>
<MdCircle className="ml-3 size-2 text-severity_alert" />
</Label>
<div className="text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraReview.reviewClassification.selectAlertsZones
</Trans>
</div>
</div>
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
{zones.map((zone) => (
<div
key={zone.name}
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
>
<Checkbox
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={alertsZones.includes(zone.name)}
onCheckedChange={() =>
handleZoneToggle("alerts.required_zones", zone.name)
}
/>
<Label
className={cn(
"font-normal",
!zone.friendly_name && "smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
</Label>
</div>
))}
</div>
</>
) : (
<div className="font-normal text-destructive">
<Trans ns="views/settings">
cameraReview.reviewClassification.noDefinedZones
</Trans>
</div>
)}
<div className="mt-2 text-sm">
{alertsZones.length > 0
? t("cameraReview.reviewClassification.zoneObjectAlertsTips", {
alertsLabels,
zone: formatList(
alertsZones.map((zone) => getZoneName(zone, cameraName)),
),
cameraName: selectCameraName,
})
: t("cameraReview.reviewClassification.objectAlertsTips", {
alertsLabels,
cameraName: selectCameraName,
})}
</div>
</div>
<div>
{zones && zones.length > 0 && (
<>
<div className="mb-2">
<Label
className={cn(
"flex flex-row items-center text-base",
detectionsZonesModified && "text-danger",
)}
>
<Trans ns="views/settings">
cameraReview.review.detections
</Trans>
<MdCircle className="ml-3 size-2 text-severity_detection" />
</Label>
{selectDetections && (
<div className="text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraReview.reviewClassification.selectDetectionsZones
</Trans>
</div>
)}
</div>
{selectDetections && (
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
{zones.map((zone) => (
<div
key={zone.name}
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
>
<Checkbox
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={detectionsZones.includes(zone.name)}
onCheckedChange={() =>
handleZoneToggle(
"detections.required_zones",
zone.name,
)
}
/>
<Label
className={cn(
"font-normal",
!zone.friendly_name && "smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
</Label>
</div>
))}
</div>
)}
<div className="mb-0 mt-3 flex flex-row items-center gap-2">
<Checkbox
id="select-detections"
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={selectDetections}
onCheckedChange={handleDetectionsToggle}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="select-detections"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans ns="views/settings">
cameraReview.reviewClassification.limitDetections
</Trans>
</label>
</div>
</div>
</>
)}
<div className="mt-2 text-sm">
{detectionsZones.length > 0 ? (
!selectDetections ? (
<Trans
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
values={{
detectionsLabels,
zone: formatList(
detectionsZones.map((zone) =>
getZoneName(zone, cameraName),
),
),
cameraName: selectCameraName,
}}
ns="views/settings"
/>
) : (
<Trans
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
values={{
detectionsLabels,
zone: formatList(
detectionsZones.map((zone) =>
getZoneName(zone, cameraName),
),
),
cameraName: selectCameraName,
}}
ns="views/settings"
/>
)
) : (
<Trans
i18nKey="cameraReview.reviewClassification.objectDetectionsTips"
values={{
detectionsLabels,
cameraName: selectCameraName,
}}
ns="views/settings"
/>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,164 @@
import { useMemo } from "react";
import useSWR from "swr";
import { Trans } from "react-i18next";
import Heading from "@/components/ui/heading";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { FrigateConfig } from "@/types/frigateConfig";
import {
useAlertsState,
useDetectionsState,
useObjectDescriptionState,
useReviewDescriptionState,
} from "@/api/ws";
import type { SectionRendererProps } from "./registry";
import CameraReviewClassification from "./CameraReviewClassification";
export default function CameraReviewStatusToggles({
selectedCamera,
formContext,
}: SectionRendererProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const cameraId = selectedCamera ?? "";
const cameraConfig = useMemo(() => {
if (config && selectedCamera) {
return config.cameras[selectedCamera];
}
}, [config, selectedCamera]);
const { payload: alertsState, send: sendAlerts } = useAlertsState(cameraId);
const { payload: detectionsState, send: sendDetections } =
useDetectionsState(cameraId);
const { payload: objDescState, send: sendObjDesc } =
useObjectDescriptionState(cameraId);
const { payload: revDescState, send: sendRevDesc } =
useReviewDescriptionState(cameraId);
if (!selectedCamera || !cameraConfig) {
return null;
}
return (
<div className="flex flex-col">
<Heading as="h4" className="my-2">
<Trans ns="views/settings">cameraReview.title</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="alerts-enabled"
className="mr-3"
checked={alertsState == "ON"}
onCheckedChange={(isChecked) => {
sendAlerts(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="alerts-enabled">
<Trans ns="views/settings">cameraReview.review.alerts</Trans>
</Label>
</div>
</div>
<div className="flex flex-col">
<div className="flex flex-row items-center">
<Switch
id="detections-enabled"
className="mr-3"
checked={detectionsState == "ON"}
onCheckedChange={(isChecked) => {
sendDetections(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="detections-enabled">
<Trans ns="views/settings">camera.review.detections</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">cameraReview.review.desc</Trans>
</div>
</div>
</div>
{cameraConfig?.objects?.genai?.enabled_in_config && (
<>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.object_descriptions.title
</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="object-descriptions-enabled"
className="mr-3"
checked={objDescState == "ON"}
onCheckedChange={(isChecked) => {
sendObjDesc(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="object-descriptions-enabled">
<Trans>button.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraReview.object_descriptions.desc
</Trans>
</div>
</div>
</>
)}
{cameraConfig?.review?.genai?.enabled_in_config && (
<>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.review_descriptions.title
</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="review-descriptions-enabled"
className="mr-3"
checked={revDescState == "ON"}
onCheckedChange={(isChecked) => {
sendRevDesc(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="review-descriptions-enabled">
<Trans>button.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraReview.review_descriptions.desc
</Trans>
</div>
</div>
</>
)}
<CameraReviewClassification
selectedCamera={selectedCamera}
formContext={formContext}
/>
</div>
);
}
@@ -0,0 +1,843 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Toaster } from "@/components/ui/sonner";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { FrigateConfig } from "@/types/frigateConfig";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useForm } from "react-hook-form";
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { CiCircleAlert } from "react-icons/ci";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import useSWR from "swr";
import { z } from "zod";
import {
useNotifications,
useNotificationSuspend,
useNotificationTest,
} from "@/api/ws";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Trans, useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { cn } from "@/lib/utils";
import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual";
import set from "lodash/set";
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
import { sanitizeSectionData } from "@/utils/configUtil";
import type { SectionRendererProps } from "./registry";
const NOTIFICATION_SERVICE_WORKER = "/notification-worker.js";
import {
SettingsGroupCard,
SPLIT_ROW_CLASS_NAME,
CONTROL_COLUMN_CLASS_NAME,
} from "@/components/card/SettingsGroupCard";
export default function NotificationsSettingsExtras({
formContext,
}: SectionRendererProps) {
const { t } = useTranslation([
"views/settings",
"common",
"components/filter",
]);
const { getLocaleDocUrl } = useDocDomain();
// roles
const isAdmin = useIsAdmin();
// status bar
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
// config
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const allCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order)
.filter((c) => c.enabled_in_config);
}, [config]);
const notificationCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.filter(
(conf) =>
conf.enabled_in_config &&
conf.notifications &&
conf.notifications.enabled_in_config,
)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const { send: sendTestNotification } = useNotificationTest();
// notification state
const [registration, setRegistration] =
useState<ServiceWorkerRegistration | null>();
const [cameraSelectionTouched, setCameraSelectionTouched] = useState(false);
useEffect(() => {
if (!("Notification" in window) || !window.isSecureContext) {
return;
}
navigator.serviceWorker
.getRegistration(NOTIFICATION_SERVICE_WORKER)
.then((worker) => {
if (worker) {
setRegistration(worker);
} else {
setRegistration(null);
}
})
.catch(() => {
setRegistration(null);
});
}, []);
// form
const formSchema = z.object({
allEnabled: z.boolean(),
email: z.string(),
cameras: z.array(z.string()),
});
const pendingDataBySection = useMemo(
() => formContext?.pendingDataBySection ?? {},
[formContext?.pendingDataBySection],
);
const pendingCameraOverrides = useMemo(() => {
const overrides: Record<string, boolean> = {};
Object.entries(pendingDataBySection).forEach(([key, data]) => {
if (!key.endsWith("::notifications")) {
return;
}
const cameraName = key.slice(0, key.indexOf("::"));
const enabled = (data as JsonObject | undefined)?.enabled;
if (typeof enabled === "boolean") {
overrides[cameraName] = enabled;
}
});
return overrides;
}, [pendingDataBySection]);
const defaultValues = useMemo(() => {
const formData = formContext?.formData as JsonObject | undefined;
const enabledValue =
typeof formData?.enabled === "boolean"
? formData.enabled
: (config?.notifications.enabled ?? false);
const emailValue =
typeof formData?.email === "string"
? formData.email
: (config?.notifications.email ?? "");
const baseEnabledSet = new Set(
notificationCameras.map((camera) => camera.name),
);
const selectedCameras = enabledValue
? []
: allCameras
.filter((camera) => {
const pendingEnabled = pendingCameraOverrides[camera.name];
if (typeof pendingEnabled === "boolean") {
return pendingEnabled;
}
return baseEnabledSet.has(camera.name);
})
.map((camera) => camera.name);
return {
allEnabled: Boolean(enabledValue),
email: typeof emailValue === "string" ? emailValue : "",
cameras: selectedCameras,
};
}, [
allCameras,
config?.notifications.email,
config?.notifications.enabled,
formContext?.formData,
notificationCameras,
pendingCameraOverrides,
]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues,
});
const watchAllEnabled = form.watch("allEnabled");
const watchCameras = form.watch("cameras");
const watchEmail = form.watch("email");
const pendingCameraOverridesRef = useRef<Set<string>>(new Set());
const resetFormState = useCallback(
(values: z.infer<typeof formSchema>) => {
form.reset(values);
setCameraSelectionTouched(false);
pendingCameraOverridesRef.current.clear();
},
[form],
);
// pending changes sync (Undo All / Save All)
const hasPendingNotifications = useMemo(
() =>
Object.keys(pendingDataBySection).some(
(key) => key === "notifications" || key.endsWith("::notifications"),
),
[pendingDataBySection],
);
const hasPendingNotificationsRef = useRef(hasPendingNotifications);
useEffect(() => {
if (!config || form.formState.isDirty || hasPendingNotifications) {
return;
}
resetFormState(defaultValues);
}, [
config,
defaultValues,
form.formState.isDirty,
hasPendingNotifications,
resetFormState,
]);
useEffect(() => {
const hadPending = hasPendingNotificationsRef.current;
hasPendingNotificationsRef.current = hasPendingNotifications;
if (hadPending && !hasPendingNotifications) {
resetFormState(defaultValues);
}
}, [hasPendingNotifications, defaultValues, resetFormState]);
useEffect(() => {
if (!formContext?.onFormDataChange) {
return;
}
const baseData =
(formContext.formData as JsonObject | undefined) ??
(config?.notifications as JsonObject | undefined);
if (!baseData) {
return;
}
const nextData = cloneDeep(baseData);
const normalizedEmail = watchEmail?.trim() ? watchEmail : null;
set(nextData, "enabled", Boolean(watchAllEnabled));
set(nextData, "email", normalizedEmail);
formContext.onFormDataChange(nextData as ConfigSectionData);
}, [config, formContext, watchAllEnabled, watchEmail]);
// camera selection overrides
const baselineCameraSelection = useMemo(() => {
if (!config) {
return [] as string[];
}
return config.notifications.enabled
? []
: notificationCameras.map((camera) => camera.name);
}, [config, notificationCameras]);
const cameraSelectionDirty = useMemo(() => {
const current = Array.isArray(watchCameras) ? watchCameras : [];
return !isEqual([...current].sort(), [...baselineCameraSelection].sort());
}, [watchCameras, baselineCameraSelection]);
useEffect(() => {
formContext?.setExtraHasChanges?.(cameraSelectionDirty);
}, [cameraSelectionDirty, formContext]);
useEffect(() => {
const onPendingDataChange = formContext?.onPendingDataChange;
if (!onPendingDataChange || !config) {
return;
}
if (!cameraSelectionTouched) {
return;
}
if (!cameraSelectionDirty) {
pendingCameraOverridesRef.current.forEach((cameraName) => {
onPendingDataChange("notifications", cameraName, null);
});
pendingCameraOverridesRef.current.clear();
setCameraSelectionTouched(false);
return;
}
const selectedCameras = Array.isArray(watchCameras) ? watchCameras : [];
allCameras.forEach((camera) => {
const desiredEnabled = watchAllEnabled
? true
: selectedCameras.includes(camera.name);
const currentNotifications = config.cameras[camera.name]?.notifications;
const currentEnabled = currentNotifications?.enabled;
if (desiredEnabled === currentEnabled) {
if (pendingCameraOverridesRef.current.has(camera.name)) {
onPendingDataChange("notifications", camera.name, null);
pendingCameraOverridesRef.current.delete(camera.name);
}
return;
}
if (!currentNotifications) {
return;
}
const nextNotifications = cloneDeep(
currentNotifications as JsonObject,
) as JsonObject;
set(nextNotifications, "enabled", desiredEnabled);
const sanitizedNotifications = sanitizeSectionData(
nextNotifications as ConfigSectionData,
["enabled_in_config", "email"],
);
onPendingDataChange("notifications", camera.name, sanitizedNotifications);
pendingCameraOverridesRef.current.add(camera.name);
});
}, [
allCameras,
cameraSelectionDirty,
cameraSelectionTouched,
config,
formContext,
watchAllEnabled,
watchCameras,
]);
const anyCameraNotificationsEnabled = useMemo(
() =>
config &&
Object.values(config.cameras).some(
(c) =>
c.enabled_in_config &&
c.notifications &&
c.notifications.enabled_in_config,
),
[config],
);
const shouldFetchPubKey = Boolean(
config &&
(config.notifications?.enabled || anyCameraNotificationsEnabled) &&
(watchAllEnabled ||
(Array.isArray(watchCameras) && watchCameras.length > 0)),
);
const { data: publicKey } = useSWR(
shouldFetchPubKey ? "notifications/pubkey" : null,
{ revalidateOnFocus: false },
);
const subscribeToNotifications = useCallback(
(workerRegistration: ServiceWorkerRegistration) => {
if (!workerRegistration) {
return;
}
addMessage(
"notification_settings",
t("notification.unsavedRegistrations"),
undefined,
"registration",
);
workerRegistration.pushManager
.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey,
})
.then((pushSubscription) => {
axios
.post("notifications/register", {
sub: pushSubscription,
})
.catch(() => {
toast.error(t("notification.toast.error.registerFailed"), {
position: "top-center",
});
pushSubscription.unsubscribe();
workerRegistration.unregister();
setRegistration(null);
});
toast.success(t("notification.toast.success.registered"), {
position: "top-center",
});
});
},
[addMessage, publicKey, t],
);
useEffect(() => {
if (watchCameras.length > 0) {
form.setValue("allEnabled", false);
}
}, [watchCameras, allCameras, form]);
useEffect(() => {
document.title = t("documentTitle.notifications");
}, [t]);
if (formContext?.level && formContext.level !== "global") {
return null;
}
if (!config) {
return <ActivityIndicator />;
}
if (!("Notification" in window) || !window.isSecureContext) {
return (
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
<div className="w-full max-w-5xl">
<SettingsGroupCard
title={t("notification.notificationSettings.title")}
>
<div className="space-y-4">
<div className="flex flex-col gap-2 text-sm text-primary-variant">
<p>{t("notification.notificationSettings.desc")}</p>
<div className="flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/notifications")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
<Alert variant="destructive">
<CiCircleAlert className="size-5" />
<AlertTitle>
{t("notification.notificationUnavailable.title")}
</AlertTitle>
<AlertDescription>
<Trans ns="views/settings">
notification.notificationUnavailable.desc
</Trans>
<div className="mt-3 flex items-center">
<Link
to={getLocaleDocUrl("configuration/authentication")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</AlertDescription>
</Alert>
</div>
</SettingsGroupCard>
</div>
</div>
);
}
return (
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
<div className={cn("w-full max-w-5xl space-y-6")}>
{isAdmin && (
<SettingsGroupCard
title={t("notification.notificationSettings.title")}
>
<div className="space-y-6">
<Form {...form}>
<div className="space-y-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<FormLabel htmlFor="notification-email">
{t("notification.email.title")}
</FormLabel>
<FormDescription className="hidden md:block">
{t("notification.email.desc")}
</FormDescription>
</div>
<div
className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}
>
<FormControl>
<Input
id="notification-email"
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
placeholder={t(
"notification.email.placeholder",
)}
{...field}
/>
</FormControl>
<FormDescription className="md:hidden">
{t("notification.email.desc")}
</FormDescription>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="cameras"
render={({ field }) => (
<FormItem className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<FormLabel className="text-base">
{t("notification.cameras.title")}
</FormLabel>
<FormDescription className="hidden md:block">
{t("notification.cameras.desc")}
</FormDescription>
</div>
<div
className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}
>
{allCameras.length > 0 ? (
<div className="w-full space-y-2 rounded-lg bg-secondary p-4">
<FormField
control={form.control}
name="allEnabled"
render={({ field: allEnabledField }) => (
<FilterSwitch
label={t("cameras.all.title", {
ns: "components/filter",
})}
isChecked={allEnabledField.value}
onCheckedChange={(checked) => {
setCameraSelectionTouched(true);
if (checked) {
form.setValue("cameras", []);
}
allEnabledField.onChange(checked);
}}
/>
)}
/>
{allCameras.map((camera) => {
const currentCameras = Array.isArray(
field.value,
)
? field.value
: [];
return (
<FilterSwitch
key={camera.name}
label={camera.name}
type="camera"
isChecked={currentCameras.includes(
camera.name,
)}
onCheckedChange={(checked) => {
setCameraSelectionTouched(true);
const newCameras = checked
? Array.from(
new Set([
...currentCameras,
camera.name,
]),
)
: currentCameras.filter(
(value) => value !== camera.name,
);
field.onChange(newCameras);
form.setValue("allEnabled", false);
}}
/>
);
})}
</div>
) : (
<div className="font-normal text-destructive">
{t("notification.cameras.noCameras")}
</div>
)}
<FormDescription className="md:hidden">
{t("notification.cameras.desc")}
</FormDescription>
<FormMessage />
</div>
</FormItem>
)}
/>
</div>
</Form>
</div>
</SettingsGroupCard>
)}
<div className="space-y-6">
<SettingsGroupCard title={t("notification.deviceSpecific")}>
<div className={cn("space-y-2", isAdmin && "md:max-w-[50%]")}>
<Button
aria-label={t("notification.registerDevice")}
className="w-full md:w-auto"
disabled={!shouldFetchPubKey || publicKey == undefined}
onClick={() => {
if (registration == null) {
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
navigator.serviceWorker
.register(NOTIFICATION_SERVICE_WORKER)
.then((workerRegistration) => {
setRegistration(workerRegistration);
if (workerRegistration.active) {
subscribeToNotifications(workerRegistration);
} else {
setTimeout(
() =>
subscribeToNotifications(
workerRegistration,
),
1000,
);
}
});
}
});
} else {
registration.pushManager
.getSubscription()
.then((pushSubscription) => {
pushSubscription?.unsubscribe();
registration.unregister();
setRegistration(null);
removeMessage(
"notification_settings",
"registration",
);
});
}
}}
>
{registration != null
? t("notification.unregisterDevice")
: t("notification.registerDevice")}
</Button>
{isAdmin && registration != null && registration.active && (
<Button
className="w-full md:w-auto"
aria-label={t("notification.sendTestNotification")}
onClick={() => sendTestNotification("notification_test")}
>
{t("notification.sendTestNotification")}
</Button>
)}
</div>
</SettingsGroupCard>
{isAdmin && notificationCameras.length > 0 && (
<SettingsGroupCard title={t("notification.globalSettings.title")}>
<div className="space-y-4">
<div className="flex max-w-xl flex-col gap-2 text-sm text-primary-variant">
<p>{t("notification.globalSettings.desc")}</p>
</div>
<div className="w-full rounded-lg bg-secondary p-5 md:max-w-2xl">
<div className="grid gap-6">
{notificationCameras.map((item) => (
<CameraNotificationSwitch
key={item.name}
config={config}
camera={item.name}
/>
))}
</div>
</div>
</div>
</SettingsGroupCard>
)}
</div>
</div>
</div>
</div>
);
}
type CameraNotificationSwitchProps = {
config?: FrigateConfig;
camera: string;
};
export function CameraNotificationSwitch({
config,
camera,
}: CameraNotificationSwitchProps) {
const { t } = useTranslation(["views/settings"]);
const { payload: notificationState, send: sendNotification } =
useNotifications(camera);
const { payload: notificationSuspendUntil, send: sendNotificationSuspend } =
useNotificationSuspend(camera);
const [isSuspended, setIsSuspended] = useState<boolean>(false);
useEffect(() => {
if (notificationSuspendUntil) {
setIsSuspended(
notificationSuspendUntil !== "0" || notificationState === "OFF",
);
}
}, [notificationSuspendUntil, notificationState]);
const handleSuspend = (duration: string) => {
setIsSuspended(true);
if (duration == "off") {
sendNotification("OFF");
} else {
sendNotificationSuspend(parseInt(duration));
}
};
const handleCancelSuspension = () => {
sendNotification("ON");
sendNotificationSuspend(0);
};
const locale = useDateLocale();
const formatSuspendedUntil = (timestamp: string) => {
if (timestamp === "0") return t("time.untilForRestart", { ns: "common" });
const time = formatUnixTimestampToDateTime(parseInt(timestamp), {
time_style: "medium",
date_style: "medium",
timezone: config?.ui.timezone,
date_format:
config?.ui.time_format == "24hour"
? t("time.formattedTimestampMonthDayHourMinute.24hour", {
ns: "common",
})
: t("time.formattedTimestampMonthDayHourMinute.12hour", {
ns: "common",
}),
locale: locale,
});
return t("time.untilForTime", { ns: "common", time });
};
return (
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col items-start justify-start">
<div className="flex flex-row items-center justify-start gap-3">
{!isSuspended ? (
<LuCheck className="size-6 text-success" />
) : (
<LuX className="size-6 text-danger" />
)}
<div className="flex flex-col">
<CameraNameLabel
className="text-md cursor-pointer text-primary smart-capitalize"
htmlFor="camera"
camera={camera}
/>
{!isSuspended ? (
<div className="flex flex-row items-center gap-2 text-sm text-success">
{t("notification.active")}
</div>
) : (
<div className="flex flex-row items-center gap-2 text-sm text-danger">
{t("notification.suspended", {
time: formatSuspendedUntil(notificationSuspendUntil),
})}
</div>
)}
</div>
</div>
</div>
{!isSuspended ? (
<Select onValueChange={handleSuspend}>
<SelectTrigger className="w-auto">
<SelectValue placeholder={t("notification.suspendTime.suspend")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">
{t("notification.suspendTime.5minutes")}
</SelectItem>
<SelectItem value="10">
{t("notification.suspendTime.10minutes")}
</SelectItem>
<SelectItem value="30">
{t("notification.suspendTime.30minutes")}
</SelectItem>
<SelectItem value="60">
{t("notification.suspendTime.1hour")}
</SelectItem>
<SelectItem value="840">
{t("notification.suspendTime.12hours")}
</SelectItem>
<SelectItem value="1440">
{t("notification.suspendTime.24hours")}
</SelectItem>
<SelectItem value="off">
{t("notification.suspendTime.untilRestart")}
</SelectItem>
</SelectContent>
</Select>
) : (
<Button
variant="destructive"
size="sm"
onClick={handleCancelSuspension}
>
{t("notification.cancelSuspension")}
</Button>
)}
</div>
);
}
@@ -0,0 +1,201 @@
import { useMemo } from "react";
import type { ComponentType } from "react";
import { useTranslation } from "react-i18next";
import cloneDeep from "lodash/cloneDeep";
import get from "lodash/get";
import set from "lodash/set";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LuPlus, LuTrash2 } from "react-icons/lu";
import { TagsWidget } from "@/components/config-form/theme/widgets/TagsWidget";
import { isJsonObject } from "@/lib/utils";
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
import type { SectionRendererProps } from "./registry";
const EMPTY_FORM_DATA: JsonObject = {};
const RoleMapTags = TagsWidget as unknown as ComponentType<{
id: string;
value: string[];
onChange: (value: unknown) => void;
schema: { title: string };
}>;
export default function ProxyRoleMap({ formContext }: SectionRendererProps) {
const { t } = useTranslation(["views/settings", "config/global"]);
const fullFormData =
(formContext?.formData as JsonObject | undefined) ?? EMPTY_FORM_DATA;
const onFormDataChange = formContext?.onFormDataChange;
const roleHeader = get(fullFormData, "header_map.role");
const hasRoleHeader =
typeof roleHeader === "string" && roleHeader.trim().length > 0;
const roleMap = useMemo(() => {
const roleMapValue = get(fullFormData, "header_map.role_map");
return isJsonObject(roleMapValue)
? (roleMapValue as Record<string, string[]>)
: {};
}, [fullFormData]);
const roleOptions = useMemo(() => {
const rolesFromConfig = formContext?.fullConfig?.auth?.roles
? Object.keys(formContext.fullConfig.auth.roles)
: [];
const roles =
rolesFromConfig.length > 0 ? rolesFromConfig : ["admin", "viewer"];
return Array.from(new Set([...roles, ...Object.keys(roleMap)])).sort();
}, [formContext?.fullConfig, roleMap]);
if (!onFormDataChange || !formContext?.formData) {
return null;
}
if (!hasRoleHeader) {
return null;
}
const usedRoles = new Set(Object.keys(roleMap));
const nextRole = roleOptions.find((role) => !usedRoles.has(role));
const updateRoleMap = (nextRoleMap: Record<string, string[]>) => {
const nextFormData = cloneDeep(fullFormData) as JsonObject;
set(nextFormData, "header_map.role_map", nextRoleMap);
onFormDataChange(nextFormData as ConfigSectionData);
};
const handleAdd = () => {
if (!nextRole) return;
updateRoleMap({
...roleMap,
[nextRole]: [],
});
};
const handleRemove = (role: string) => {
const next = { ...roleMap };
delete next[role];
updateRoleMap(next);
};
const handleRoleChange = (currentRole: string, newRole: string) => {
if (currentRole === newRole) return;
const next = { ...roleMap } as Record<string, string[]>;
const groups = next[currentRole] ?? [];
delete next[currentRole];
next[newRole] = groups;
updateRoleMap(next);
};
const handleGroupsChange = (role: string, groups: unknown) => {
updateRoleMap({
...roleMap,
[role]: Array.isArray(groups) ? groups : [],
});
};
const roleMapLabel = t("proxy.header_map.role_map.label", {
ns: "config/global",
});
const roleMapDescription = t("proxy.header_map.role_map.description", {
ns: "config/global",
});
return (
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-sm font-medium">{roleMapLabel}</Label>
<p className="text-xs text-muted-foreground">{roleMapDescription}</p>
</div>
{Object.keys(roleMap).length === 0 && (
<p className="text-sm italic text-muted-foreground">
{t("configForm.roleMap.empty", { ns: "views/settings" })}
</p>
)}
{Object.entries(roleMap).map(([role, groups], index) => {
const rowId = `role-map-${role}-${index}`;
const roleLabel = t("configForm.roleMap.roleLabel", {
ns: "views/settings",
});
const groupsLabel = t("configForm.roleMap.groupsLabel", {
ns: "views/settings",
});
const normalizedGroups = Array.isArray(groups) ? groups : [];
return (
<div key={rowId} className="grid grid-cols-12 items-start gap-2">
<div className="col-span-12 space-y-2 md:col-span-4">
<Label htmlFor={`${rowId}-role`}>{roleLabel}</Label>
<Select
value={role}
onValueChange={(next) => handleRoleChange(role, next)}
>
<SelectTrigger id={`${rowId}-role`} className="w-full">
<SelectValue placeholder={roleLabel} />
</SelectTrigger>
<SelectContent>
{roleOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="col-span-12 space-y-2 md:col-span-7">
<Label htmlFor={`${rowId}-groups`}>{groupsLabel}</Label>
<RoleMapTags
id={`${rowId}-groups`}
value={normalizedGroups}
onChange={(next) => handleGroupsChange(role, next)}
schema={{ title: groupsLabel }}
/>
</div>
<div className="col-span-12 flex items-center md:col-span-1 md:justify-center md:pt-7">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemove(role)}
aria-label={t("configForm.roleMap.remove", {
ns: "views/settings",
})}
title={t("configForm.roleMap.remove", {
ns: "views/settings",
})}
>
<LuTrash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAdd}
disabled={!nextRole}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("configForm.roleMap.addMapping", {
ns: "views/settings",
})}
</Button>
</div>
);
}
@@ -0,0 +1,106 @@
import { useState } from "react";
import axios from "axios";
import { Button, buttonVariants } from "@/components/ui/button";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
export default function SemanticSearchReindex() {
const { t } = useTranslation("views/settings");
const [isLoading, setIsLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const onReindex = async () => {
setIsLoading(true);
try {
const res = await axios.put("/reindex");
if (res.status === 202) {
toast.success(t("enrichments.semanticSearch.reindexNow.success"), {
position: "top-center",
});
} else {
toast.error(
t("enrichments.semanticSearch.reindexNow.error", {
errorMessage: res.statusText,
}),
{ position: "top-center" },
);
}
} catch (caught) {
const error = caught as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
error.response?.data?.message || error.response?.data?.detail || "";
toast.error(
t("enrichments.semanticSearch.reindexNow.error", {
errorMessage: errorMessage || undefined,
}),
{ position: "top-center" },
);
} finally {
setIsLoading(false);
}
};
return (
<>
<div className="flex flex-col space-y-1">
<div className="flex">
<Button
variant="default"
size="sm"
onClick={() => setIsDialogOpen(true)}
disabled={isLoading}
aria-label={t("enrichments.semanticSearch.reindexNow.label")}
>
{t("enrichments.semanticSearch.reindexNow.label")}
</Button>
</div>
<div className="mt-2 text-xs text-muted-foreground">
<Trans ns="views/settings">
enrichments.semanticSearch.reindexNow.desc
</Trans>
</div>
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("enrichments.semanticSearch.reindexNow.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
<Trans ns="views/settings">
enrichments.semanticSearch.reindexNow.confirmDesc
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setIsDialogOpen(false)}>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "select" })}
onClick={async () => {
await onReindex();
setIsDialogOpen(false);
}}
>
{t("enrichments.semanticSearch.reindexNow.confirmButton")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</>
);
}
@@ -0,0 +1,57 @@
import type { ComponentType } from "react";
import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
import ProxyRoleMap from "./ProxyRoleMap";
import NotificationsSettingsExtras from "./NotificationsSettingsExtras";
import type { ConfigFormContext } from "@/types/configForm";
// Props that will be injected into all section renderers
export type SectionRendererProps = {
selectedCamera?: string;
setUnsavedChanges?: (hasChanges: boolean) => void;
formContext?: ConfigFormContext;
[key: string]: unknown; // Allow additional props from uiSchema
};
export type RendererComponent = ComponentType<SectionRendererProps>;
export type SectionRenderers = Record<
string,
Record<string, RendererComponent>
>;
// Section renderers registry
// Used to register custom renderer components for specific config sections.
// Maps a section key (e.g., `semantic_search`) to a mapping of renderer
// names to React components. These names are referenced from `uiSchema`
// descriptors (e.g., `{ "ui:after": { render: "SemanticSearchReindex" } }`) and
// are resolved by `FieldTemplate` through `formContext.renderers`.
//
// RUNTIME PROPS INJECTION:
// All renderers automatically receive the following props from BaseSection:
// - selectedCamera?: string - The current camera name (camera-level only)
// - setUnsavedChanges?: (hasChanges: boolean) => void - Callback to signal unsaved state
//
// Additional static props can be passed via uiSchema:
// { "ui:after": { render: "MyRenderer", props: { customProp: "value" } } }
//
// ADDING NEW RENDERERS:
// 1. Create your component accepting SectionRendererProps
// 2. Import and add it to the appropriate section in this registry
// 3. Reference it in your section's uiSchema using the { render: "ComponentName" } syntax
export const sectionRenderers: SectionRenderers = {
semantic_search: {
SemanticSearchReindex,
},
review: {
CameraReviewStatusToggles,
},
proxy: {
ProxyRoleMap,
},
notifications: {
NotificationsSettingsExtras,
},
};
export default sectionRenderers;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
import { useMemo } from "react";
import { ConfigSection } from "./BaseSection";
import type { BaseSectionProps, SectionConfig } from "./BaseSection";
import { getSectionConfig } from "@/utils/configUtil";
export type ConfigSectionTemplateProps = BaseSectionProps & {
sectionKey: string;
sectionConfig?: SectionConfig;
};
export function ConfigSectionTemplate({
sectionKey,
level,
sectionConfig,
...rest
}: ConfigSectionTemplateProps) {
const defaultConfig = useMemo(
() => getSectionConfig(sectionKey, level),
[sectionKey, level],
);
return (
<ConfigSection
sectionPath={sectionKey}
defaultConfig={defaultConfig}
level={level}
sectionConfig={sectionConfig ?? defaultConfig}
{...rest}
/>
);
}
export default ConfigSectionTemplate;
@@ -0,0 +1,14 @@
// Config Form Section Components
// Reusable components for both global and camera-level settings
export {
ConfigSection,
type BaseSectionProps,
type SectionConfig,
type CreateSectionOptions,
type ConfigSectionProps,
} from "./BaseSection";
export {
ConfigSectionTemplate,
type ConfigSectionTemplateProps,
} from "./ConfigSectionTemplate";
@@ -0,0 +1,203 @@
/**
* Special case handling for config sections with schema/default issues.
*
* Some sections have schema patterns that cause false "Modified" indicators
* when navigating to them due to how defaults are applied. This utility
* centralizes the logic for detecting and handling these cases.
*/
import { RJSFSchema } from "@rjsf/utils";
import { applySchemaDefaults } from "@/lib/config-schema";
import { isJsonObject } from "@/lib/utils";
import { JsonObject, JsonValue } from "@/types/configForm";
/**
* Sections that require special handling at the global level.
* Add new section paths here as needed.
*/
const SPECIAL_CASE_SECTIONS = ["motion", "detectors"] as const;
/**
* Check if a section requires special case handling.
*/
export function isSpecialCaseSection(
sectionPath: string,
level: string,
): boolean {
return (
level === "global" &&
SPECIAL_CASE_SECTIONS.includes(
sectionPath as (typeof SPECIAL_CASE_SECTIONS)[number],
)
);
}
/**
* 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.
*/
export function modifySchemaForSection(
sectionPath: string,
level: string,
schema: RJSFSchema | undefined,
): RJSFSchema | undefined {
if (!schema || !isSpecialCaseSection(sectionPath, level)) {
return schema;
}
// detectors: Remove default to prevent merging with stored keys
if (sectionPath === "detectors" && "default" in schema) {
const { default: _, ...schemaWithoutDefault } = schema;
return schemaWithoutDefault;
}
return schema;
}
/**
* Get effective defaults for sections with special schema patterns.
*
* - 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,
level: string,
schema: RJSFSchema | undefined,
schemaDefaults: unknown,
): unknown {
if (!isSpecialCaseSection(sectionPath, level) || !schema) {
return schemaDefaults;
}
// motion: Derive defaults from non-null anyOf branch
if (sectionPath === "motion") {
const anyOfSchemas = (schema as { anyOf?: unknown[] }).anyOf;
if (!anyOfSchemas || !Array.isArray(anyOfSchemas)) {
return schemaDefaults;
}
// Find the non-null motion config schema
const motionSchema = anyOfSchemas.find(
(s) =>
typeof s === "object" &&
s !== null &&
(s as { type?: string }).type !== "null",
);
if (!motionSchema) {
return schemaDefaults;
}
return applySchemaDefaults(motionSchema as RJSFSchema, {});
}
// detectors: Return empty object to avoid adding default keys
if (sectionPath === "detectors") {
return {};
}
return schemaDefaults;
}
/**
* Sanitize overrides payloads for section-specific quirks.
*/
export function sanitizeOverridesForSection(
sectionPath: string,
level: string,
overrides: unknown,
): unknown {
if (!overrides || !isJsonObject(overrides)) {
return overrides;
}
if (sectionPath === "ffmpeg" && level === "camera") {
const overridesObj = overrides as JsonObject;
const inputs = overridesObj.inputs;
if (!Array.isArray(inputs)) {
return overrides;
}
const cleanedInputs = inputs.map((input) => {
if (!isJsonObject(input)) {
return input;
}
const cleanedInput = { ...input } as JsonObject;
["global_args", "hwaccel_args", "input_args"].forEach((key) => {
const value = cleanedInput[key];
if (Array.isArray(value) && value.length === 0) {
delete cleanedInput[key];
}
});
return cleanedInput;
});
return {
...overridesObj,
inputs: cleanedInputs,
};
}
const flattenRecordWithDots = (
value: JsonObject,
prefix: string = "",
): JsonObject => {
const flattened: JsonObject = {};
Object.entries(value).forEach(([key, entry]) => {
const nextKey = prefix ? `${prefix}.${key}` : key;
if (isJsonObject(entry)) {
Object.assign(flattened, flattenRecordWithDots(entry, nextKey));
} else {
flattened[nextKey] = entry as JsonValue;
}
});
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;
if (isJsonObject(logs)) {
return {
...overridesObj,
logs: flattenRecordWithDots(logs),
};
}
}
if (sectionPath === "environment_vars") {
const overridesObj = overrides as JsonObject;
return flattenRecordWithDots(overridesObj);
}
return overrides;
}
@@ -0,0 +1,136 @@
/**
* Shared UI components for config form templates and fields.
*/
import { canExpand } from "@rjsf/utils";
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
import { Button } from "@/components/ui/button";
import { LuPlus, LuChevronDown, LuChevronRight } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { ReactNode } from "react";
interface AddPropertyButtonProps {
/** Callback fired when the add button is clicked */
onAddProperty?: () => void;
/** JSON Schema to determine expandability */
schema: RJSFSchema;
/** UI Schema for expansion checks */
uiSchema?: UiSchema;
/** Current form data for expansion checks */
formData?: unknown;
/** Whether the form is disabled */
disabled?: boolean;
/** Whether the form is read-only */
readonly?: boolean;
}
/**
* Add property button for RJSF objects with additionalProperties.
* Shows "Add" button that allows adding new key-value pairs to objects.
*/
export function AddPropertyButton({
onAddProperty,
schema,
uiSchema,
formData,
disabled,
readonly,
}: AddPropertyButtonProps) {
const { t } = useTranslation(["common"]);
const canAdd =
Boolean(onAddProperty) && canExpand(schema, uiSchema, formData);
if (!canAdd) {
return null;
}
return (
<Button
type="button"
variant="outline"
size="sm"
onClick={onAddProperty}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("button.add", { ns: "common", defaultValue: "Add" })}
</Button>
);
}
interface AdvancedCollapsibleProps {
/** Number of advanced fields */
count: number;
/** Whether the collapsible is open */
open: boolean;
/** Callback when open state changes */
onOpenChange: (open: boolean) => void;
/** Content to show when expanded */
children: ReactNode;
/** Use root-level label variant (longer text) */
isRoot?: boolean;
/** Button size - defaults to undefined (default) for root, "sm" for nested */
buttonSize?: "sm" | "default" | "lg" | "icon";
}
/**
* Collapsible section for advanced form fields.
* Provides consistent styling and i18n labels for advanced settings.
*/
export function AdvancedCollapsible({
count,
open,
onOpenChange,
children,
isRoot = false,
buttonSize,
}: AdvancedCollapsibleProps) {
const { t } = useTranslation(["views/settings", "common"]);
if (count === 0) {
return null;
}
const effectiveSize = buttonSize ?? (isRoot ? undefined : "sm");
const label = isRoot
? t("configForm.advancedSettingsCount", {
ns: "views/settings",
defaultValue: "Advanced Settings ({{count}})",
count,
})
: t("configForm.advancedCount", {
ns: "views/settings",
defaultValue: "Advanced ({{count}})",
count,
});
return (
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size={effectiveSize}
className="w-full justify-start gap-2 pl-0 hover:bg-transparent"
>
{open ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
{label}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-4 rounded-lg border border-border/60 bg-secondary/40 p-4">
{children}
</CollapsibleContent>
</Collapsible>
);
}
@@ -0,0 +1,426 @@
import type {
ErrorSchema,
FieldProps,
RJSFSchema,
UiSchema,
} from "@rjsf/utils";
import { toFieldPathId } from "@rjsf/utils";
import { cloneDeep, isEqual } from "lodash";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { applySchemaDefaults } from "@/lib/config-schema";
import { mergeUiSchema } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
LuChevronDown,
LuChevronRight,
LuPlus,
LuTrash2,
} from "react-icons/lu";
import type { ConfigFormContext } from "@/types/configForm";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
type FfmpegInput = {
path?: string;
roles?: string[];
hwaccel_args?: unknown;
};
const asInputList = (formData: unknown): FfmpegInput[] => {
if (!Array.isArray(formData)) {
return [];
}
return formData.filter(
(item): item is FfmpegInput => 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 hasDetectRole = (input: FfmpegInput): boolean =>
Array.isArray(input.roles) && input.roles.includes("detect");
const hasHwaccelValue = (value: unknown): boolean => {
if (value === null || value === undefined || value === "") {
return false;
}
if (Array.isArray(value)) {
return value.length > 0;
}
return true;
};
const normalizeNonDetectHwaccel = (inputs: FfmpegInput[]): FfmpegInput[] =>
inputs.map((input) => {
if (hasDetectRole(input)) {
return input;
}
if (!hasHwaccelValue(input.hwaccel_args)) {
return input;
}
return {
...input,
hwaccel_args: undefined,
};
});
export function CameraInputsField(props: FieldProps) {
const {
schema,
uiSchema,
formData,
onChange,
fieldPathId,
registry,
idSchema,
errorSchema,
disabled,
readonly,
hideError,
onBlur,
onFocus,
} = props;
const formContext = registry?.formContext as ConfigFormContext | undefined;
const isCameraLevel = formContext?.level === "camera";
const effectiveNamespace = isCameraLevel ? "config/cameras" : "config/global";
const { t, i18n } = useTranslation([
"common",
"views/settings",
effectiveNamespace,
]);
const inputs = useMemo(() => asInputList(formData), [formData]);
const arraySchema = schema as RJSFSchema;
const itemSchema = useMemo(() => getItemSchema(arraySchema), [arraySchema]);
const itemProperties = useMemo(
() => getItemProperties(itemSchema),
[itemSchema],
);
const itemUiSchema = useMemo(
() =>
((uiSchema as { items?: UiSchema } | undefined)?.items ?? {}) as UiSchema,
[uiSchema],
);
const SchemaField = registry.fields.SchemaField;
const [openByIndex, setOpenByIndex] = useState<Record<number, boolean>>({});
useEffect(() => {
setOpenByIndex((previous) => {
const next: Record<number, boolean> = {};
for (let index = 0; index < inputs.length; index += 1) {
next[index] = previous[index] ?? true;
}
return next;
});
}, [inputs.length]);
useEffect(() => {
const normalized = normalizeNonDetectHwaccel(inputs);
if (!isEqual(normalized, inputs)) {
onChange(normalized, fieldPathId.path);
}
}, [fieldPathId.path, inputs, onChange]);
const handleFieldValueChange = useCallback(
(index: number, fieldName: string, nextValue: unknown) => {
const nextInputs = cloneDeep(inputs);
const item =
(nextInputs[index] as Record<string, unknown> | undefined) ??
({} as Record<string, unknown>);
item[fieldName] = nextValue;
nextInputs[index] = item;
onChange(normalizeNonDetectHwaccel(nextInputs), fieldPathId.path);
},
[fieldPathId.path, inputs, onChange],
);
const handleAddInput = useCallback(() => {
const base = itemSchema
? (applySchemaDefaults(itemSchema) as FfmpegInput)
: ({} as FfmpegInput);
const nextInputs = normalizeNonDetectHwaccel([...inputs, base]);
onChange(nextInputs, fieldPathId.path);
setOpenByIndex((previous) => ({ ...previous, [inputs.length]: true }));
}, [fieldPathId.path, inputs, itemSchema, onChange]);
const handleRemoveInput = useCallback(
(index: number) => {
const nextInputs = inputs.filter(
(_, currentIndex) => currentIndex !== index,
);
onChange(nextInputs, 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;
});
},
[fieldPathId.path, inputs, onChange],
);
const renderField = useCallback(
(
index: number,
fieldName: string,
options?: {
extraUiSchema?: UiSchema;
showSchemaDescription?: boolean;
},
) => {
if (!SchemaField) {
return null;
}
const fieldSchema = itemProperties[fieldName];
if (!fieldSchema) {
return null;
}
const itemData = inputs[index] as Record<string, unknown>;
const itemPath = [...fieldPathId.path, index];
const itemFieldPathId = toFieldPathId(
fieldName,
registry.globalFormOptions,
itemPath,
);
const itemErrors = (
errorSchema as Record<string, ErrorSchema> | undefined
)?.[index] as Record<string, ErrorSchema> | undefined;
const fieldErrorSchema = itemErrors?.[fieldName];
const baseUiSchema =
(itemUiSchema[fieldName] as UiSchema | undefined) ?? ({} as UiSchema);
const mergedUiSchema = options?.extraUiSchema
? mergeUiSchema(baseUiSchema, options.extraUiSchema)
: baseUiSchema;
const fieldTranslationDescriptionKey = `ffmpeg.inputs.${fieldName}.description`;
const translatedDescription = i18n.exists(
fieldTranslationDescriptionKey,
{
ns: effectiveNamespace,
},
)
? t(fieldTranslationDescriptionKey, { ns: effectiveNamespace })
: "";
const fieldDescription =
typeof fieldSchema.description === "string" &&
fieldSchema.description.length > 0
? fieldSchema.description
: translatedDescription;
const handleScopedFieldChange = (
nextValue: unknown,
_path: unknown,
_errors?: ErrorSchema,
_id?: string,
) => {
handleFieldValueChange(index, fieldName, nextValue);
};
return (
<div className="space-y-1">
<SchemaField
name={fieldName}
schema={fieldSchema}
uiSchema={mergedUiSchema}
fieldPathId={itemFieldPathId}
formData={itemData?.[fieldName]}
errorSchema={fieldErrorSchema}
onChange={handleScopedFieldChange}
onBlur={onBlur}
onFocus={onFocus}
registry={registry}
disabled={disabled}
readonly={readonly}
hideError={hideError}
/>
{options?.showSchemaDescription && fieldDescription ? (
<p className="text-xs text-muted-foreground">{fieldDescription}</p>
) : null}
</div>
);
},
[
SchemaField,
itemProperties,
inputs,
fieldPathId.path,
registry,
errorSchema,
itemUiSchema,
i18n,
handleFieldValueChange,
effectiveNamespace,
onBlur,
onFocus,
disabled,
readonly,
hideError,
t,
],
);
const baseId = idSchema?.$id ?? "ffmpeg_inputs";
return (
<div className="space-y-3">
{inputs.map((input, index) => {
const open = openByIndex[index] ?? true;
const itemTitle = t("configForm.cameraInputs.itemTitle", {
ns: "views/settings",
index: index + 1,
});
const itemPath =
typeof input.path === "string" ? input.path.trim() : "";
return (
<Card key={`${baseId}-${index}`} className="w-full">
<Collapsible
open={open}
onOpenChange={(nextOpen) =>
setOpenByIndex((previous) => ({
...previous,
[index]: nextOpen,
}))
}
>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
<div className="flex items-center justify-between gap-4">
<CardTitle className="text-sm">
<span>{itemTitle}</span>
{itemPath ? (
<span className="mt-1 block text-xs font-normal text-muted-foreground">
{itemPath}
</span>
) : null}
</CardTitle>
{open ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 p-4 pt-0">
<div className="w-full">
{renderField(index, "path", {
extraUiSchema: {
"ui:widget": "CameraPathWidget",
"ui:options": {
size: "full",
splitLayout: false,
},
},
showSchemaDescription: true,
})}
</div>
<div className="w-full">{renderField(index, "roles")}</div>
{renderField(index, "input_args")}
{hasDetectRole(input)
? renderField(index, "hwaccel_args", {
extraUiSchema: {
"ui:options": {
allowInherit: true,
},
},
})
: null}
{renderField(index, "output_args")}
<div className="flex justify-end">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveInput(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>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
})}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddInput}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("button.add", { ns: "common" })}
</Button>
</div>
);
}
export default CameraInputsField;
@@ -0,0 +1,891 @@
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 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 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,
getTypeLabel,
isSingleInstanceType,
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;
}
setTypeErrors((prev) => {
const { [key]: _, ...rest } = prev;
return rest;
});
const nextDetectors = {
...detectors,
[key]: getDetectorDefaults(nextType),
} as JsonObject;
updateDetectors(nextDetectors);
},
[
detectors,
getDetectorDefaults,
getTypeLabel,
isSingleInstanceType,
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,
},
} as UiSchema;
const withGlobalHidden = mergeUiSchema(baseUiSchema, globalHidden);
const withTypeHidden = mergeUiSchema(withGlobalHidden, hiddenOverrides);
const withTypeHiddenAndOptions = mergeUiSchema(
withTypeHidden,
typeHidden,
);
return mergeUiSchema(withTypeHiddenAndOptions, nestedOverrides);
},
[globalHiddenFields, hiddenByType, 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,587 @@
/**
* LayoutGridField - RJSF field for responsive, semantic grid layouts
*
* Overview:
* - Apply a responsive grid to object properties using `ui:layoutGrid` while
* preserving the default `ObjectFieldTemplate` behavior (cards, nested
* collapsibles, add button, and i18n).
* - Falls back to the original template when `ui:layoutGrid` is not present.
*
* Capabilities:
* - 12-column grid logic. `ui:col` accepts a number (1-12) or a Tailwind class
* string (e.g. "col-span-12 md:col-span-4") for responsive column widths.
* - Per-row and global class overrides:
* - `ui:options.layoutGrid.rowClassName` (default: "grid-cols-12") is merged
* with the base `grid gap-4` classes.
* - `ui:options.layoutGrid.advancedRowClassName` (default: "grid-cols-12")
* controls advanced-section rows.
* - Per-row `ui:className` and per-column `ui:className`/`className` are
* supported for fine-grained layout control.
* - Optional `useGridForAdvanced` (via `ui:options.layoutGrid`) to toggle
* whether advanced fields use the grid or fall back to stacked layout.
* - Integrates with `ui:groups` to show semantic group labels (resolved via
* `config/groups` i18n). If a layout row contains fields from the same group,
* that row shows the group label above it; leftover or ungrouped fields are
* rendered after the configured rows.
* - Hidden fields (`ui:widget: "hidden"`) are ignored.
*
* Internationalization
* - Advanced collapsible labels use `label.advancedSettingsCount` and
* `label.advancedCount` in the `common` namespace.
* - Group labels are looked up in `config/groups` (uses `sectionI18nPrefix`
* when available).
*
* Usage examples:
* Basic:
* {
* "ui:field": "LayoutGridField",
* "ui:layoutGrid": [
* { "ui:row": ["field1", "field2"] },
* { "ui:row": ["field3"] }
* ]
* }
*
* Custom columns and responsive classes:
* {
* "ui:field": "LayoutGridField",
* "ui:options": {
* "layoutGrid": { "rowClassName": "grid-cols-12 md:grid-cols-6", "useGridForAdvanced": true }
* },
* "ui:layoutGrid": [
* {
* "ui:row": [
* { "field1": { "ui:col": "col-span-12 md:col-span-4", "ui:className": "md:col-start-2" } },
* { "field2": { "ui:col": 4 } }
* ],
* "ui:className": "gap-6"
* }
* ]
* }
*
* Groups and rows:
* {
* "ui:field": "LayoutGridField",
* "ui:groups": { "resolution": ["fps","width","height"], "tracking": ["min_initialized","max_disappeared"] },
* "ui:layoutGrid": [
* { "ui:row": ["enabled"] },
* { "ui:row": ["fps","width","height"] }
* ]
* }
*
* Notes:
* - `ui:layoutGrid` must be an array; non-array values are ignored and the
* default ObjectFieldTemplate is used instead.
* - This implementation adheres to RJSF patterns (use `ui:options`,
* `ui:className`, and `ui:layoutGrid` as documented) while adding a few
* Frigate-specific conveniences (defaults and Tailwind-friendly class
* handling).
*/
import type { FieldProps, ObjectFieldTemplateProps } from "@rjsf/utils";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { ConfigFormContext } from "@/types/configForm";
import {
getDomainFromNamespace,
hasOverrideAtPath,
humanizeKey,
} from "../utils";
import { AddPropertyButton, AdvancedCollapsible } from "../components";
type LayoutGridColumnConfig = {
"ui:col"?: number | string;
"ui:className"?: string;
className?: string;
};
type LayoutRow = {
"ui:row": Array<string | Record<string, LayoutGridColumnConfig>>;
"ui:className"?: string;
className?: string;
};
type LayoutGrid = LayoutRow[];
type LayoutGridOptions = {
rowClassName?: string;
advancedRowClassName?: string;
useGridForAdvanced?: boolean;
};
interface PropertyElement {
name: string;
content: React.ReactElement;
}
function isObjectLikeElement(item: PropertyElement) {
const fieldSchema = item.content.props?.schema as
| { type?: string | string[] }
| undefined;
return fieldSchema?.type === "object";
}
// Custom ObjectFieldTemplate wrapper that applies grid layout
function GridLayoutObjectFieldTemplate(
props: ObjectFieldTemplateProps,
originalObjectFieldTemplate: React.ComponentType<ObjectFieldTemplateProps>,
) {
const {
uiSchema,
properties,
registry,
schema,
onAddProperty,
formData,
disabled,
readonly,
} = props;
const formContext = registry?.formContext as ConfigFormContext | undefined;
const { t } = useTranslation(["common", "config/groups"]);
// Use the original ObjectFieldTemplate passed as parameter, not from registry
const ObjectFieldTemplate = originalObjectFieldTemplate;
// Get layout configuration
const layoutGrid = Array.isArray(uiSchema?.["ui:layoutGrid"])
? (uiSchema?.["ui:layoutGrid"] as LayoutGrid)
: [];
const layoutGridOptions =
(uiSchema?.["ui:options"] as { layoutGrid?: LayoutGridOptions } | undefined)
?.layoutGrid ?? {};
const baseRowClassName = layoutGridOptions.rowClassName ?? "grid-cols-12";
const advancedRowClassName =
layoutGridOptions.advancedRowClassName ?? "grid-cols-12";
const useGridForAdvanced = layoutGridOptions.useGridForAdvanced ?? true;
const groupDefinitions =
(uiSchema?.["ui:groups"] as Record<string, string[]> | undefined) || {};
const overrides = formContext?.overrides;
const fieldPath = props.fieldPathId.path;
const isPathModified = (path: Array<string | number>) =>
hasOverrideAtPath(overrides, path, formContext?.formData);
// Override the properties rendering with grid layout
const isHiddenProp = (prop: (typeof properties)[number]) =>
prop.content.props.uiSchema?.["ui:widget"] === "hidden";
const visibleProps = properties.filter((prop) => !isHiddenProp(prop));
// Separate regular and advanced properties
const advancedProps = visibleProps.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
);
const regularProps = visibleProps.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
);
const hasModifiedAdvanced = advancedProps.some((prop) =>
isPathModified([...fieldPath, prop.name]),
);
const [showAdvanced, setShowAdvanced] = useState(hasModifiedAdvanced);
// If no layout grid is defined, use the default template
if (layoutGrid.length === 0) {
return <ObjectFieldTemplate {...props} />;
}
const domain = getDomainFromNamespace(formContext?.i18nNamespace);
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
const getGroupLabel = (groupKey: string) => {
if (domain && sectionI18nPrefix) {
return t(`${sectionI18nPrefix}.${domain}.${groupKey}`, {
ns: "config/groups",
defaultValue: humanizeKey(groupKey),
});
}
return t(`groups.${groupKey}`, {
ns: "config/groups",
defaultValue: humanizeKey(groupKey),
});
};
// Render fields using the layout grid structure
const renderGridLayout = (items: PropertyElement[], rowClassName: string) => {
if (!items.length) {
return null;
}
// Create a map for quick lookup
const itemMap = new Map(items.map((item) => [item.name, item]));
const renderedFields = new Set<string>();
return (
<div className="space-y-4">
{layoutGrid.map((rowDef, rowIndex) => {
const rowItems = rowDef["ui:row"];
const cols: React.ReactNode[] = [];
rowItems.forEach((colDef, colIndex) => {
let fieldName: string;
let colSpan: number | string = 12; // Default to full width
let colClassName: string | undefined;
if (typeof colDef === "string") {
fieldName = colDef;
} else {
// Object with field name as key and ui:col as value
const entries = Object.entries(colDef);
if (entries.length === 0) return;
const [name, config] = entries[0];
fieldName = name;
colSpan = config["ui:col"] ?? 12;
colClassName = config["ui:className"] ?? config.className;
}
const item = itemMap.get(fieldName);
if (!item) return;
renderedFields.add(fieldName);
// Calculate column width class (using 12-column grid)
const colSpanClass =
typeof colSpan === "string" ? colSpan : `col-span-${colSpan}`;
const colClass = cn(colSpanClass, colClassName);
cols.push(
<div key={`${rowIndex}-${colIndex}`} className={colClass}>
{item.content}
</div>,
);
});
if (cols.length === 0) return null;
const rowClass = cn(
"grid gap-4",
rowClassName,
rowDef["ui:className"],
rowDef.className,
);
return (
<div key={rowIndex} className={rowClass}>
{cols}
</div>
);
})}
{Array.from(itemMap.keys())
.filter((name) => !renderedFields.has(name))
.map((name) => {
const item = itemMap.get(name);
return item ? (
<div key={name} className="space-y-6">
{item.content}
</div>
) : null;
})}
</div>
);
};
const renderGroupedGridLayout = (
items: PropertyElement[],
rowClassName: string,
) => {
if (!items.length) {
return null;
}
if (Object.keys(groupDefinitions).length === 0) {
return renderGridLayout(items, rowClassName);
}
const itemMap = new Map(items.map((item) => [item.name, item]));
const renderedFields = new Set<string>();
const renderedGroups = new Set<string>();
const groupMap = new Map<string, string>();
Object.entries(groupDefinitions).forEach(([groupKey, fields]) => {
fields.forEach((field) => {
groupMap.set(field, groupKey);
});
});
const rows = layoutGrid
.map((rowDef, rowIndex) => {
const rowItems = rowDef["ui:row"];
const cols: React.ReactNode[] = [];
const rowFieldNames: string[] = [];
rowItems.forEach((colDef, colIndex) => {
let fieldName: string;
let colSpan: number | string = 12;
let colClassName: string | undefined;
if (typeof colDef === "string") {
fieldName = colDef;
} else {
const entries = Object.entries(colDef);
if (entries.length === 0) return;
const [name, config] = entries[0];
fieldName = name;
colSpan = config["ui:col"] ?? 12;
colClassName = config["ui:className"] ?? config.className;
}
const item = itemMap.get(fieldName);
if (!item) return;
renderedFields.add(fieldName);
rowFieldNames.push(fieldName);
const colSpanClass =
typeof colSpan === "string" ? colSpan : `col-span-${colSpan}`;
const colClass = cn(colSpanClass, colClassName);
cols.push(
<div key={`${rowIndex}-${colIndex}`} className={colClass}>
{item.content}
</div>,
);
});
if (cols.length === 0) return null;
const rowClass = cn(
"grid gap-4",
rowClassName,
rowDef["ui:className"],
rowDef.className,
);
const rowGroupKeys = rowFieldNames
.map((name) => groupMap.get(name))
.filter(Boolean) as string[];
const rowGroupKey =
rowGroupKeys.length > 0 &&
rowGroupKeys.length === rowFieldNames.length &&
new Set(rowGroupKeys).size === 1
? rowGroupKeys[0]
: undefined;
const showGroupLabel = rowGroupKey && !renderedGroups.has(rowGroupKey);
if (showGroupLabel) {
renderedGroups.add(rowGroupKey);
}
return (
<div
key={rowIndex}
className={cn(
"space-y-4",
rowGroupKey &&
"rounded-lg border border-border/70 bg-card/30 p-4",
)}
>
{showGroupLabel && (
<div className="border-b border-border/60 pb-2 text-sm font-semibold text-primary-variant">
{getGroupLabel(rowGroupKey)}
</div>
)}
<div className={rowClass}>{cols}</div>
</div>
);
})
.filter(Boolean);
const remainingItems = Array.from(itemMap.keys())
.filter((name) => !renderedFields.has(name))
.map((name) => itemMap.get(name))
.filter(Boolean) as PropertyElement[];
const groupedLeftovers = new Map<string, PropertyElement[]>();
const ungroupedLeftovers: PropertyElement[] = [];
remainingItems.forEach((item) => {
const groupKey = groupMap.get(item.name);
if (groupKey) {
const existing = groupedLeftovers.get(groupKey);
if (existing) {
existing.push(item);
} else {
groupedLeftovers.set(groupKey, [item]);
}
} else {
ungroupedLeftovers.push(item);
}
});
const leftoverSections: React.ReactNode[] = [];
groupedLeftovers.forEach((groupItems, groupKey) => {
const showGroupLabel = !renderedGroups.has(groupKey);
if (showGroupLabel) {
renderedGroups.add(groupKey);
}
leftoverSections.push(
<div
key={groupKey}
className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4"
>
{showGroupLabel && (
<div className="border-b border-border/60 pb-2 text-sm font-semibold text-primary-variant">
{getGroupLabel(groupKey)}
</div>
)}
<div className="space-y-6">
{groupItems.map((item) => (
<div key={item.name}>{item.content}</div>
))}
</div>
</div>,
);
});
if (ungroupedLeftovers.length > 0) {
leftoverSections.push(
<div
key="ungrouped-leftovers"
className={cn(
"space-y-6",
(rows.length > 0 || groupedLeftovers.size > 0) && "pt-2",
)}
>
{ungroupedLeftovers.map((item) => (
<div
key={item.name}
className={cn(
groupedLeftovers.size > 0 &&
!isObjectLikeElement(item) &&
"px-4",
)}
>
{item.content}
</div>
))}
</div>,
);
}
return (
<div className="space-y-6">
{rows}
{leftoverSections}
</div>
);
};
const renderStackedLayout = (items: PropertyElement[]) => {
if (!items.length) {
return null;
}
return (
<div className="space-y-6">
{items.map((item) => (
<div key={item.name}>{item.content}</div>
))}
</div>
);
};
const regularLayout = renderGroupedGridLayout(regularProps, baseRowClassName);
const advancedLayout = useGridForAdvanced
? renderGroupedGridLayout(advancedProps, advancedRowClassName)
: renderStackedLayout(advancedProps);
// Create modified props with custom property rendering
// Render using the original template but with our custom content
const isRoot = registry?.rootSchema === props.schema;
if (isRoot) {
return (
<div className="space-y-6">
{regularLayout}
<AddPropertyButton
onAddProperty={onAddProperty}
schema={schema}
uiSchema={uiSchema}
formData={formData}
disabled={disabled}
readonly={readonly}
/>
<AdvancedCollapsible
count={advancedProps.length}
open={showAdvanced}
onOpenChange={setShowAdvanced}
isRoot
>
{advancedLayout}
</AdvancedCollapsible>
</div>
);
}
// We need to inject our custom rendering into the template
// Since we can't directly modify the template's internal rendering,
// we'll render the full structure ourselves
return (
<ObjectFieldTemplate {...props}>
<div className="space-y-4">
{regularLayout}
<AddPropertyButton
onAddProperty={onAddProperty}
schema={schema}
uiSchema={uiSchema}
formData={formData}
disabled={disabled}
readonly={readonly}
/>
<AdvancedCollapsible
count={advancedProps.length}
open={showAdvanced}
onOpenChange={setShowAdvanced}
>
{advancedLayout}
</AdvancedCollapsible>
</div>
</ObjectFieldTemplate>
);
}
export function LayoutGridField(props: FieldProps) {
const { registry, schema, uiSchema, idSchema, formData } = props;
// Store the original ObjectFieldTemplate before any modifications
const originalObjectFieldTemplate = registry.templates.ObjectFieldTemplate;
// Get the ObjectField component from the registry
const ObjectField = registry.fields.ObjectField;
// Create a modified registry with our custom template
// But we'll pass the original template to it to prevent circular reference
const gridObjectFieldTemplate = useCallback(
(tProps: ObjectFieldTemplateProps) =>
GridLayoutObjectFieldTemplate(tProps, originalObjectFieldTemplate),
[originalObjectFieldTemplate],
);
const modifiedRegistry = useMemo(
() => ({
...registry,
templates: {
...registry.templates,
ObjectFieldTemplate: gridObjectFieldTemplate,
},
}),
[registry, gridObjectFieldTemplate],
);
// Delegate to ObjectField with the modified registry
return (
<ObjectField
{...props}
registry={modifiedRegistry}
schema={schema}
uiSchema={uiSchema}
idSchema={idSchema}
formData={formData}
/>
);
}
@@ -0,0 +1,253 @@
import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import {
LuChevronDown,
LuChevronRight,
LuPlus,
LuTrash2,
} from "react-icons/lu";
import type { ConfigFormContext } from "@/types/configForm";
import get from "lodash/get";
import { isSubtreeModified } from "../utils";
type ReplaceRule = {
pattern?: string;
replacement?: string;
};
function getItemSchema(schema: RJSFSchema): RJSFSchema | undefined {
const items = schema.items;
if (!items || typeof items !== "object" || Array.isArray(items)) {
return undefined;
}
return items as RJSFSchema;
}
function getPropertyTitle(itemSchema: RJSFSchema | undefined, key: string) {
const props = (itemSchema as { properties?: Record<string, RJSFSchema> })
?.properties;
const title = props?.[key]?.title;
return typeof title === "string" ? title : undefined;
}
export function ReplaceRulesField(props: FieldProps) {
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
const { t } = useTranslation(["common"]);
const rules: ReplaceRule[] = useMemo(() => {
if (!Array.isArray(formData)) {
return [];
}
return formData as ReplaceRule[];
}, [formData]);
const itemSchema = useMemo(
() => getItemSchema(schema as RJSFSchema),
[schema],
);
const title = (schema as RJSFSchema).title;
const description = (schema as RJSFSchema).description;
const patternTitle = getPropertyTitle(itemSchema, "pattern");
const replacementTitle = getPropertyTitle(itemSchema, "replacement");
const hasItems = rules.length > 0;
const emptyPath = useMemo(() => [] as FieldPathList, []);
const fieldPath =
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
emptyPath;
const isModified = useMemo(() => {
const baselineRoot = formContext?.baselineFormData;
const baselineValue = baselineRoot
? get(baselineRoot, fieldPath)
: undefined;
return isSubtreeModified(
rules,
baselineValue,
formContext?.overrides,
fieldPath,
formContext?.formData,
);
}, [fieldPath, formContext, rules]);
const [open, setOpen] = useState(hasItems || isModified);
useEffect(() => {
if (isModified) {
setOpen(true);
}
}, [isModified]);
useEffect(() => {
if (hasItems) {
setOpen(true);
}
}, [hasItems]);
const handleAdd = useCallback(() => {
const next = [...rules, { pattern: "", replacement: "" }];
onChange(next, fieldPath);
}, [fieldPath, onChange, rules]);
const handleRemove = useCallback(
(index: number) => {
const next = rules.filter((_, i) => i !== index);
onChange(next, fieldPath);
},
[fieldPath, onChange, rules],
);
const handleUpdate = useCallback(
(index: number, patch: Partial<ReplaceRule>) => {
const next = rules.map((rule, i) => {
if (i !== index) {
return rule;
}
return {
...rule,
...patch,
};
});
onChange(next, fieldPath);
},
[fieldPath, onChange, rules],
);
const baseId = idSchema?.$id || "replace_rules";
const deleteLabel = t("button.delete", {
ns: "common",
defaultValue: "Delete",
});
return (
<Card className="w-full">
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
<div className="flex items-center justify-between">
<div>
<CardTitle
className={cn("text-sm", isModified && "text-danger")}
>
{title}
</CardTitle>
{description && (
<p className="mt-1 text-xs text-muted-foreground">
{description}
</p>
)}
</div>
{open ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 p-4 pt-0">
{rules.length > 0 && (
<div className="grid grid-cols-12 gap-2">
<div className="col-span-12 space-y-2 md:col-span-6">
{patternTitle && (
<Label className="text-xs text-muted-foreground">
{patternTitle}
</Label>
)}
</div>
<div className="col-span-12 space-y-2 md:col-span-5">
{replacementTitle && (
<Label className="text-xs text-muted-foreground">
{replacementTitle}
</Label>
)}
</div>
</div>
)}
<div className="space-y-2">
{rules.map((rule, index) => {
const patternId = `${baseId}-${index}-pattern`;
const replacementId = `${baseId}-${index}-replacement`;
return (
<div
key={index}
className="grid grid-cols-12 items-start gap-2"
>
<div className="col-span-12 md:col-span-6">
<Input
id={patternId}
value={rule?.pattern ?? ""}
disabled={disabled || readonly}
onChange={(e) =>
handleUpdate(index, { pattern: e.target.value })
}
/>
</div>
<div className="col-span-12 md:col-span-5">
<Input
id={replacementId}
value={rule?.replacement ?? ""}
disabled={disabled || readonly}
onChange={(e) =>
handleUpdate(index, { replacement: e.target.value })
}
/>
</div>
<div className="col-span-12 flex items-center md:col-span-1 md:justify-center">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemove(index)}
disabled={disabled || readonly}
aria-label={deleteLabel}
title={deleteLabel}
>
<LuTrash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
<div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAdd}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("button.add", { ns: "common", defaultValue: "Add" })}
</Button>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
export default ReplaceRulesField;
@@ -0,0 +1,4 @@
// Custom RJSF Fields
export { LayoutGridField } from "./LayoutGridField";
export { DetectorHardwareField } from "./DetectorHardwareField";
export { ReplaceRulesField } from "./ReplaceRulesField";
@@ -0,0 +1,60 @@
// Utilities for handling anyOf with null patterns
import type { StrictRJSFSchema } from "@rjsf/utils";
/**
* Checks if a schema is anyOf/oneOf with exactly [Type, null].
* This indicates a nullable field in Pydantic schemas.
*/
export function isNullableUnionSchema(schema: StrictRJSFSchema): boolean {
const union = schema.anyOf ?? schema.oneOf;
if (!union || !Array.isArray(union) || union.length !== 2) {
return false;
}
let hasNull = false;
let nonNullCount = 0;
for (const item of union) {
if (typeof item !== "object" || item === null) {
return false;
}
const itemSchema = item as StrictRJSFSchema;
if (itemSchema.type === "null") {
hasNull = true;
} else {
nonNullCount += 1;
}
}
return hasNull && nonNullCount === 1;
}
/**
* Backwards-compatible alias for nullable fields
*/
export function isSimpleNullableField(schema: StrictRJSFSchema): boolean {
return isNullableUnionSchema(schema);
}
/**
* Get the non-null schema from an anyOf containing [Type, null]
*/
export function getNonNullSchema(
schema: StrictRJSFSchema,
): StrictRJSFSchema | null {
const union = schema.anyOf ?? schema.oneOf;
if (!union || !Array.isArray(union)) {
return null;
}
return (
(union.find(
(item) =>
typeof item === "object" &&
item !== null &&
(item as StrictRJSFSchema).type !== "null",
) as StrictRJSFSchema) || null
);
}
@@ -0,0 +1,95 @@
// Custom RJSF Theme for Frigate
// Maps RJSF templates and widgets to shadcn/ui components
import type {
WidgetProps,
FieldTemplateProps,
RegistryWidgetsType,
RegistryFieldsType,
TemplatesType,
} from "@rjsf/utils";
import { SwitchWidget } from "./widgets/SwitchWidget";
import { SelectWidget } from "./widgets/SelectWidget";
import { TextWidget } from "./widgets/TextWidget";
import { PasswordWidget } from "./widgets/PasswordWidget";
import { RangeWidget } from "./widgets/RangeWidget";
import { TagsWidget } from "./widgets/TagsWidget";
import { ColorWidget } from "./widgets/ColorWidget";
import { TextareaWidget } from "./widgets/TextareaWidget";
import { SwitchesWidget } from "./widgets/SwitchesWidget";
import { ObjectLabelSwitchesWidget } from "./widgets/ObjectLabelSwitchesWidget";
import { AudioLabelSwitchesWidget } from "./widgets/AudioLabelSwitchesWidget";
import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget";
import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget";
import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
import { InputRolesWidget } from "./widgets/InputRolesWidget";
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
import { CameraPathWidget } from "./widgets/CameraPathWidget";
import { FieldTemplate } from "./templates/FieldTemplate";
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
import { ArrayFieldTemplate } from "./templates/ArrayFieldTemplate";
import { ArrayFieldItemTemplate } from "./templates/ArrayFieldItemTemplate";
import { BaseInputTemplate } from "./templates/BaseInputTemplate";
import { DescriptionFieldTemplate } from "./templates/DescriptionFieldTemplate";
import { TitleFieldTemplate } from "./templates/TitleFieldTemplate";
import { ErrorListTemplate } from "./templates/ErrorListTemplate";
import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate";
import { WrapIfAdditionalTemplate } from "./templates/WrapIfAdditionalTemplate";
import { LayoutGridField } from "./fields/LayoutGridField";
import { DetectorHardwareField } from "./fields/DetectorHardwareField";
import { ReplaceRulesField } from "./fields/ReplaceRulesField";
import { CameraInputsField } from "./fields/CameraInputsField";
export interface FrigateTheme {
widgets: RegistryWidgetsType;
templates: Partial<TemplatesType>;
fields: RegistryFieldsType;
}
export const frigateTheme: FrigateTheme = {
widgets: {
// Override default widgets with shadcn/ui styled versions
TextWidget: TextWidget,
PasswordWidget: PasswordWidget,
SelectWidget: SelectWidget,
CheckboxWidget: SwitchWidget,
ArrayAsTextWidget: ArrayAsTextWidget,
FfmpegArgsWidget: FfmpegArgsWidget,
CameraPathWidget: CameraPathWidget,
inputRoles: InputRolesWidget,
// Custom widgets
switch: SwitchWidget,
password: PasswordWidget,
select: SelectWidget,
range: RangeWidget,
tags: TagsWidget,
color: ColorWidget,
textarea: TextareaWidget,
switches: SwitchesWidget,
objectLabels: ObjectLabelSwitchesWidget,
audioLabels: AudioLabelSwitchesWidget,
zoneNames: ZoneSwitchesWidget,
timezoneSelect: TimezoneSelectWidget,
},
templates: {
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
ObjectFieldTemplate: ObjectFieldTemplate,
ArrayFieldTemplate: ArrayFieldTemplate,
ArrayFieldItemTemplate: ArrayFieldItemTemplate,
BaseInputTemplate: BaseInputTemplate as React.ComponentType<WidgetProps>,
DescriptionFieldTemplate: DescriptionFieldTemplate,
TitleFieldTemplate: TitleFieldTemplate,
ErrorListTemplate: ErrorListTemplate,
MultiSchemaFieldTemplate: MultiSchemaFieldTemplate,
WrapIfAdditionalTemplate: WrapIfAdditionalTemplate,
},
fields: {
LayoutGridField: LayoutGridField,
DetectorHardwareField: DetectorHardwareField,
ReplaceRulesField: ReplaceRulesField,
CameraInputsField: CameraInputsField,
},
};
@@ -0,0 +1,5 @@
// RJSF Custom Theme
// Maps RJSF components to existing shadcn/ui components
export { frigateTheme } from "./frigateTheme";
export type { FrigateTheme } from "./frigateTheme";
@@ -0,0 +1,58 @@
import type {
ArrayFieldItemTemplateProps,
FormContextType,
RJSFSchema,
StrictRJSFSchema,
} from "@rjsf/utils";
import { getTemplate, getUiOptions } from "@rjsf/utils";
/**
* Custom ArrayFieldItemTemplate to ensure array item content uses full width
* while keeping action buttons aligned to the right.
*/
export function ArrayFieldItemTemplate<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = FormContextType,
>(props: ArrayFieldItemTemplateProps<T, S, F>) {
const {
children,
buttonsProps,
displayLabel,
hasDescription,
hasToolbar,
uiSchema,
registry,
} = props;
const uiOptions = getUiOptions<T, S, F>(uiSchema);
const ArrayFieldItemButtonsTemplate = getTemplate<
"ArrayFieldItemButtonsTemplate",
T,
S,
F
>("ArrayFieldItemButtonsTemplate", registry, uiOptions);
const margin = hasDescription ? -6 : 22;
return (
<div className="w-full">
<div className="mb-2 flex w-full flex-row items-start gap-2">
<div className="min-w-0 flex-1">{children}</div>
{hasToolbar && (
<div
className="flex shrink-0 items-start justify-end gap-2"
style={{
marginLeft: "5px",
marginTop: displayLabel ? `${margin}px` : undefined,
}}
>
<ArrayFieldItemButtonsTemplate {...buttonsProps} />
</div>
)}
</div>
</div>
);
}
export default ArrayFieldItemTemplate;
@@ -0,0 +1,60 @@
// Array Field Template - renders array fields with add/remove controls
import type { ArrayFieldTemplateProps } from "@rjsf/utils";
import { Button } from "@/components/ui/button";
import { LuPlus } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
export function ArrayFieldTemplate(props: ArrayFieldTemplateProps) {
const { items, canAdd, onAddClick, disabled, readonly, schema } = props;
const { t } = useTranslation(["common"]);
// Simple items (strings, numbers) render inline
const isSimpleType =
schema.items &&
typeof schema.items === "object" &&
"type" in schema.items &&
["string", "number", "integer", "boolean"].includes(
schema.items.type as string,
);
return (
<div className="space-y-3">
{items.length === 0 && !canAdd && (
<p className="text-sm italic text-muted-foreground">
{t("no_items", { ns: "common", defaultValue: "No items" })}
</p>
)}
{items.map((element, index) => {
// RJSF items are pre-rendered React elements, render them directly
return (
<div
key={element.key || index}
className={cn(
"flex w-full items-start gap-2",
!isSimpleType && "flex-col",
)}
>
{element}
</div>
);
})}
{canAdd && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onAddClick}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("button.add", { ns: "common", defaultValue: "Add" })}
</Button>
)}
</div>
);
}
@@ -0,0 +1,48 @@
// Base Input Template - default input wrapper
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { getSizedFieldClassName } from "../utils";
export function BaseInputTemplate(props: WidgetProps) {
const {
id,
type,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
const inputType = type || "text";
const fieldClassName = getSizedFieldClassName(options, "xs");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (inputType === "number") {
const num = parseFloat(val);
onChange(val === "" ? undefined : isNaN(num) ? undefined : num);
} else {
onChange(val === "" ? undefined : val);
}
};
return (
<Input
id={id}
type={inputType}
className={fieldClassName}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || ""}
onChange={handleChange}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
/>
);
}
@@ -0,0 +1,37 @@
// Description Field Template
import type { DescriptionFieldProps } from "@rjsf/utils";
import { useTranslation } from "react-i18next";
import { ConfigFormContext } from "@/types/configForm";
export function DescriptionFieldTemplate(props: DescriptionFieldProps) {
const { description, id } = props;
const formContext = (
props as { registry?: { formContext?: ConfigFormContext } }
).registry?.formContext;
const isCameraLevel = formContext?.level === "camera";
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
const effectiveNamespace = isCameraLevel ? "config/cameras" : "config/global";
const { t, i18n } = useTranslation([effectiveNamespace, "common"]);
let resolvedDescription = description;
// Support nested keys for both camera-level and consolidated global namespace
if (sectionI18nPrefix && effectiveNamespace) {
const descriptionKey = `${sectionI18nPrefix}.description`;
if (i18n.exists(descriptionKey, { ns: effectiveNamespace })) {
resolvedDescription = t(descriptionKey, { ns: effectiveNamespace });
}
}
if (!resolvedDescription) {
return null;
}
return (
<span id={id} className="text-sm text-muted-foreground">
{resolvedDescription}
</span>
);
}
@@ -0,0 +1,193 @@
// Error List Template - displays form-level errors
import type {
ErrorListProps,
RJSFSchema,
RJSFValidationError,
} from "@rjsf/utils";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { LuCircleAlert } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { buildTranslationPath, humanizeKey } from "../utils";
import type { ConfigFormContext } from "@/types/configForm";
type ErrorSchemaNode = RJSFSchema & {
properties?: Record<string, RJSFSchema>;
items?: RJSFSchema | RJSFSchema[];
additionalProperties?: boolean | RJSFSchema;
title?: string;
};
const parsePropertyPath = (property: string): Array<string | number> => {
const normalizedProperty = property.replace(/^\./, "").trim();
if (!normalizedProperty) {
return [];
}
return normalizedProperty
.split(".")
.filter(Boolean)
.map((segment) => {
const maybeIndex = Number(segment);
return Number.isInteger(maybeIndex) ? maybeIndex : segment;
});
};
const resolveSchemaNodeForPath = (
schema: RJSFSchema | undefined,
segments: Array<string | number>,
): ErrorSchemaNode | undefined => {
if (!schema) {
return undefined;
}
let currentSchema: ErrorSchemaNode | undefined = schema as ErrorSchemaNode;
for (const segment of segments) {
if (!currentSchema) {
return undefined;
}
if (typeof segment === "number") {
const items = currentSchema.items;
if (Array.isArray(items)) {
currentSchema = items[0] as ErrorSchemaNode | undefined;
} else {
currentSchema = items as ErrorSchemaNode | undefined;
}
continue;
}
const nextFromProperties = currentSchema.properties?.[segment];
if (nextFromProperties) {
currentSchema = nextFromProperties as ErrorSchemaNode;
continue;
}
const additionalProperties = currentSchema.additionalProperties;
if (
additionalProperties &&
typeof additionalProperties === "object" &&
!Array.isArray(additionalProperties)
) {
currentSchema = additionalProperties as ErrorSchemaNode;
continue;
}
return undefined;
}
return currentSchema;
};
const resolveErrorFieldLabel = ({
error,
schema,
formContext,
t,
i18n,
}: {
error: RJSFValidationError;
schema: RJSFSchema | undefined;
formContext?: ConfigFormContext;
t: (key: string, options?: Record<string, unknown>) => string;
i18n: ReturnType<typeof useTranslation>["i18n"];
}): string | undefined => {
const segments = parsePropertyPath(error.property || "");
if (segments.length === 0) {
return undefined;
}
const stringSegments = segments.filter(
(segment): segment is string => typeof segment === "string",
);
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
const effectiveNamespace =
formContext?.level === "camera"
? "config/cameras"
: formContext?.i18nNamespace;
const translationPath = buildTranslationPath(
stringSegments,
sectionI18nPrefix,
formContext,
);
if (effectiveNamespace && translationPath) {
const prefixedTranslationKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.label`
: undefined;
const translationKey = `${translationPath}.label`;
if (
prefixedTranslationKey &&
i18n.exists(prefixedTranslationKey, { ns: effectiveNamespace })
) {
return t(prefixedTranslationKey, { ns: effectiveNamespace });
}
if (i18n.exists(translationKey, { ns: effectiveNamespace })) {
return t(translationKey, { ns: effectiveNamespace });
}
}
const schemaNode = resolveSchemaNodeForPath(schema, segments);
if (schemaNode?.title && schemaNode.title.trim().length > 0) {
return schemaNode.title;
}
const fallbackSegment =
[...stringSegments].reverse().find((segment) => segment.length > 0) ||
(typeof segments[segments.length - 1] === "string"
? (segments[segments.length - 1] as string)
: undefined);
return fallbackSegment ? humanizeKey(fallbackSegment) : undefined;
};
export function ErrorListTemplate(props: ErrorListProps) {
const { errors, schema } = props;
const formContext = (
props as { registry?: { formContext?: ConfigFormContext } }
).registry?.formContext;
const { t, i18n } = useTranslation([
formContext?.level === "camera"
? "config/cameras"
: formContext?.i18nNamespace || "config/global",
"common",
]);
if (!errors || errors.length === 0) {
return null;
}
return (
<Alert variant="destructive" className="mb-4">
<LuCircleAlert className="h-4 w-4" />
<AlertTitle>{t("validation_errors", { ns: "common" })}</AlertTitle>
<AlertDescription>
<ul className="mt-2 list-inside list-disc space-y-1">
{errors.map((error: RJSFValidationError, index: number) => {
const fieldLabel = resolveErrorFieldLabel({
error,
schema,
formContext,
t,
i18n,
});
return (
<li key={index} className="text-sm">
{fieldLabel && (
<span className="font-medium">{fieldLabel}: </span>
)}
{error.message}
</li>
);
})}
</ul>
</AlertDescription>
</Alert>
);
}
@@ -0,0 +1,616 @@
// Field Template - wraps each form field with label and description
import { FieldTemplateProps, StrictRJSFSchema, UiSchema } from "@rjsf/utils";
import {
getTemplate,
getUiOptions,
ADDITIONAL_PROPERTY_FLAG,
} from "@rjsf/utils";
import { ComponentType, ReactNode } from "react";
import { isValidElement } from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { isNullableUnionSchema } from "../fields/nullableUtils";
import { getTranslatedLabel } from "@/utils/i18n";
import { ConfigFormContext } from "@/types/configForm";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { requiresRestartForFieldPath } from "@/utils/configUtil";
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
import {
buildTranslationPath,
getFilterObjectLabel,
hasOverrideAtPath,
humanizeKey,
normalizeFieldValue,
} from "../utils";
import { normalizeOverridePath } from "../utils/overrides";
import get from "lodash/get";
import isEqual from "lodash/isEqual";
import { SPLIT_ROW_CLASS_NAME } from "@/components/card/SettingsGroupCard";
function _isArrayItemInAdditionalProperty(
pathSegments: Array<string | number>,
): boolean {
// // If we find a numeric index, this is an array item
for (let i = 0; i < pathSegments.length; i++) {
const segment = pathSegments[i];
if (typeof segment === "number") {
// Consider any array item as being inside additional properties if it's not at the root level
return i > 0;
}
}
return false;
}
type FieldRenderSpec =
| ReactNode
| ComponentType<unknown>
| {
render: string;
props?: Record<string, unknown>;
};
export function FieldTemplate(props: FieldTemplateProps) {
const {
id,
label,
children,
classNames,
style,
errors,
help,
description,
hidden,
required,
displayLabel,
schema,
uiSchema,
registry,
fieldPathId,
onKeyRename,
onKeyRenameBlur,
onRemoveProperty,
rawDescription,
rawErrors,
formData: fieldFormData,
disabled,
readonly,
} = props;
// Get i18n namespace from form context (passed through registry)
const formContext = registry?.formContext as ConfigFormContext | undefined;
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
const sectionI18nPrefix = formContext?.sectionI18nPrefix as
| string
| undefined;
const isCameraLevel = formContext?.level === "camera";
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
const { t, i18n } = useTranslation([
effectiveNamespace || i18nNamespace || "common",
i18nNamespace || "common",
"views/settings",
]);
const { getLocaleDocUrl } = useDocDomain();
if (hidden) {
return <div className="hidden">{children}</div>;
}
// Get UI options
const uiOptionsFromSchema = uiSchema?.["ui:options"] || {};
const suppressDescription = uiOptionsFromSchema.suppressDescription === true;
const showArrayItemDescription =
uiOptionsFromSchema.showArrayItemDescription === true;
// Determine field characteristics
const isBoolean =
schema.type === "boolean" ||
(Array.isArray(schema.type) && schema.type.includes("boolean"));
const isObjectField =
schema.type === "object" ||
(Array.isArray(schema.type) && schema.type.includes("object"));
const isNullableUnion = isNullableUnionSchema(schema as StrictRJSFSchema);
const isAdditionalProperty = ADDITIONAL_PROPERTY_FLAG in schema;
const suppressMultiSchema =
(uiSchema?.["ui:options"] as UiSchema["ui:options"] | undefined)
?.suppressMultiSchema === true;
const schemaTypes = Array.isArray(schema.type)
? schema.type
: schema.type
? [schema.type]
: [];
const nonNullSchemaTypes = schemaTypes.filter((type) => type !== "null");
const isScalarValueField =
nonNullSchemaTypes.length === 1 &&
["string", "number", "integer"].includes(nonNullSchemaTypes[0]);
// Only suppress labels/descriptions if this is a multi-schema field (anyOf/oneOf) with suppressMultiSchema flag
// This prevents duplicate labels while still showing the inner field's label
const isMultiSchemaWrapper =
(schema.anyOf || schema.oneOf) && (suppressMultiSchema || isNullableUnion);
const useSplitBooleanLayout =
uiOptionsFromSchema.splitLayout !== false &&
isBoolean &&
!isMultiSchemaWrapper &&
!isObjectField &&
!isAdditionalProperty;
const forceSplitLayout = uiOptionsFromSchema.forceSplitLayout === true;
const useSplitLayout =
uiOptionsFromSchema.splitLayout !== false &&
(isScalarValueField || forceSplitLayout) &&
!isBoolean &&
!isMultiSchemaWrapper &&
!isObjectField &&
!isAdditionalProperty;
// Get translation path for this field
const pathSegments = fieldPathId.path.filter(
(segment): segment is string => typeof segment === "string",
);
// Check if this is an array item inside an object with additionalProperties
const isArrayItemInAdditionalProp = _isArrayItemInAdditionalProperty(
fieldPathId.path,
);
// Conditions for showing descriptions/docs links
const shouldShowDescription =
!isMultiSchemaWrapper &&
!isObjectField &&
!isAdditionalProperty &&
(!isArrayItemInAdditionalProp || showArrayItemDescription) &&
!suppressDescription;
const translationPath = buildTranslationPath(
pathSegments,
sectionI18nPrefix,
formContext,
);
const fieldPath = fieldPathId.path;
const overrides = formContext?.overrides;
const baselineFormData = formContext?.baselineFormData;
const normalizedFieldPath = normalizeOverridePath(
fieldPath,
formContext?.formData,
);
let baselineValue = baselineFormData
? get(baselineFormData, normalizedFieldPath)
: undefined;
if (baselineValue === undefined || baselineValue === null) {
if (schema.default !== undefined && schema.default !== null) {
baselineValue = schema.default;
}
}
const isBaselineModified =
baselineFormData !== undefined &&
!isEqual(
normalizeFieldValue(fieldFormData),
normalizeFieldValue(baselineValue),
);
const isModified = baselineFormData
? isBaselineModified
: hasOverrideAtPath(overrides, fieldPath, formContext?.formData);
const filterObjectLabel = getFilterObjectLabel(pathSegments);
const translatedFilterObjectLabel = filterObjectLabel
? getTranslatedLabel(filterObjectLabel, "object")
: undefined;
const fieldDocsKey = translationPath || pathSegments.join(".");
const fieldDocsPath = fieldDocsKey
? formContext?.fieldDocs?.[fieldDocsKey]
: undefined;
const fieldDocsUrl = fieldDocsPath
? getLocaleDocUrl(fieldDocsPath)
: undefined;
const restartRequired = formContext?.restartRequired;
const defaultRequiresRestart = formContext?.requiresRestart ?? true;
const fieldRequiresRestart = requiresRestartForFieldPath(
normalizedFieldPath,
restartRequired,
defaultRequiresRestart,
);
// Use schema title/description as primary source (from JSON Schema)
const schemaTitle = schema.title;
const schemaDescription = schema.description;
// Try to get translated label, falling back to schema title, then RJSF label
let finalLabel = label;
if (effectiveNamespace && translationPath) {
// Prefer camera-scoped translations when a section prefix is provided
const prefixedTranslationKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.label`
: undefined;
const translationKey = `${translationPath}.label`;
if (
prefixedTranslationKey &&
i18n.exists(prefixedTranslationKey, { ns: effectiveNamespace })
) {
finalLabel = t(prefixedTranslationKey, { ns: effectiveNamespace });
} else if (i18n.exists(translationKey, { ns: effectiveNamespace })) {
finalLabel = t(translationKey, { ns: effectiveNamespace });
} else if (schemaTitle) {
finalLabel = schemaTitle;
} else if (translatedFilterObjectLabel) {
const filtersIndex = pathSegments.indexOf("filters");
const isFilterObjectField =
filtersIndex > -1 && pathSegments.length === filtersIndex + 2;
if (isFilterObjectField) {
finalLabel = translatedFilterObjectLabel;
} else {
// Try to get translated field label, fall back to humanized
const fieldName = pathSegments[pathSegments.length - 1] || "";
let fieldLabel = schemaTitle;
if (!fieldLabel) {
const fieldTranslationKey = `${fieldName}.label`;
const prefixedFieldTranslationKey =
sectionI18nPrefix &&
!fieldTranslationKey.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${fieldTranslationKey}`
: undefined;
if (
prefixedFieldTranslationKey &&
effectiveNamespace &&
i18n.exists(prefixedFieldTranslationKey, { ns: effectiveNamespace })
) {
fieldLabel = t(prefixedFieldTranslationKey, {
ns: effectiveNamespace,
});
} else if (
effectiveNamespace &&
i18n.exists(fieldTranslationKey, { ns: effectiveNamespace })
) {
fieldLabel = t(fieldTranslationKey, { ns: effectiveNamespace });
} else {
fieldLabel = humanizeKey(fieldName);
}
}
if (fieldLabel) {
finalLabel = t("configForm.filters.objectFieldLabel", {
ns: "views/settings",
field: fieldLabel,
label: translatedFilterObjectLabel,
});
}
}
}
} else if (schemaTitle) {
finalLabel = schemaTitle;
} else if (translatedFilterObjectLabel) {
const filtersIndex = pathSegments.indexOf("filters");
const isFilterObjectField =
filtersIndex > -1 && pathSegments.length === filtersIndex + 2;
if (isFilterObjectField) {
finalLabel = translatedFilterObjectLabel;
} else {
// Try to get translated field label, fall back to humanized
const fieldName = pathSegments[pathSegments.length - 1] || "";
let fieldLabel = schemaTitle;
if (!fieldLabel) {
const fieldTranslationKey = `${fieldName}.label`;
const prefixedFieldTranslationKey =
sectionI18nPrefix &&
!fieldTranslationKey.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${fieldTranslationKey}`
: undefined;
if (
prefixedFieldTranslationKey &&
effectiveNamespace &&
i18n.exists(prefixedFieldTranslationKey, { ns: effectiveNamespace })
) {
fieldLabel = t(prefixedFieldTranslationKey, {
ns: effectiveNamespace,
});
} else if (
effectiveNamespace &&
i18n.exists(fieldTranslationKey, { ns: effectiveNamespace })
) {
fieldLabel = t(fieldTranslationKey, { ns: effectiveNamespace });
} else {
fieldLabel = humanizeKey(fieldName);
}
}
if (fieldLabel) {
finalLabel = t("configForm.filters.objectFieldLabel", {
ns: "views/settings",
field: fieldLabel,
label: translatedFilterObjectLabel,
});
}
}
}
// Try to get translated description, falling back to schema description
let finalDescription = description || "";
if (effectiveNamespace && translationPath) {
const prefixedDescriptionKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.description`
: undefined;
const descriptionKey = `${translationPath}.description`;
if (
prefixedDescriptionKey &&
i18n.exists(prefixedDescriptionKey, { ns: effectiveNamespace })
) {
finalDescription = t(prefixedDescriptionKey, { ns: effectiveNamespace });
} else if (i18n.exists(descriptionKey, { ns: effectiveNamespace })) {
finalDescription = t(descriptionKey, { ns: effectiveNamespace });
} else if (schemaDescription) {
finalDescription = schemaDescription;
}
} else if (schemaDescription) {
finalDescription = schemaDescription;
}
const uiOptions = getUiOptions(uiSchema);
const beforeSpec = uiSchema?.["ui:before"] as FieldRenderSpec | undefined;
const afterSpec = uiSchema?.["ui:after"] as FieldRenderSpec | undefined;
const renderCustom = (spec: FieldRenderSpec | undefined) => {
if (spec === undefined || spec === null) {
return null;
}
if (isValidElement(spec) || typeof spec === "string") {
return spec;
}
if (typeof spec === "number") {
return <span>{spec}</span>;
}
if (typeof spec === "function") {
const SpecComponent = spec as ComponentType<unknown>;
return <SpecComponent />;
}
if (typeof spec === "object" && "render" in spec) {
const renderKey = spec.render;
const renderers = formContext?.renderers;
const RenderComponent = renderers?.[renderKey];
if (RenderComponent) {
return (
<RenderComponent {...(spec.props ?? {})} formContext={formContext} />
);
}
}
return null;
};
const beforeContent = renderCustom(beforeSpec);
const afterContent = renderCustom(afterSpec);
const WrapIfAdditionalTemplate = getTemplate(
"WrapIfAdditionalTemplate",
registry,
uiOptions,
);
const shouldRenderStandardLabel =
displayLabel &&
finalLabel &&
!isBoolean &&
!useSplitLayout &&
!isMultiSchemaWrapper &&
!isObjectField &&
!isAdditionalProperty;
const shouldRenderSplitLabel =
displayLabel &&
finalLabel &&
!isMultiSchemaWrapper &&
!isObjectField &&
!isAdditionalProperty;
const shouldRenderBooleanLabel = displayLabel && finalLabel;
const renderDocsLink = (className?: string) => {
if (!fieldDocsUrl || !shouldShowDescription) {
return null;
}
return (
<div
className={cn(
"flex items-center text-xs text-primary-variant",
className,
)}
>
<Link
to={fieldDocsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
);
};
const renderDescription = (className?: string) => {
if (!finalDescription || !shouldShowDescription) {
return null;
}
return (
<p className={cn("text-xs text-muted-foreground", className)}>
{finalDescription}
</p>
);
};
const renderStandardLabel = () => {
if (!shouldRenderStandardLabel) {
return null;
}
return (
<Label
htmlFor={id}
className={cn(
"text-sm font-medium",
isModified && "text-danger",
errors && errors.props?.errors?.length > 0 && "text-destructive",
)}
>
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
</Label>
);
};
const renderBooleanLabel = () => {
if (!shouldRenderBooleanLabel) {
return null;
}
return (
<Label
htmlFor={id}
className={cn("text-sm font-medium", isModified && "text-danger")}
>
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
</Label>
);
};
const renderSplitLabel = () => {
if (!shouldRenderSplitLabel) {
return null;
}
return (
<Label
htmlFor={id}
className={cn(
"text-sm font-medium",
isModified && "text-danger",
errors && errors.props?.errors?.length > 0 && "text-destructive",
)}
>
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
</Label>
);
};
const renderBooleanSplitLayout = () => (
<>
<div className="space-y-1.5 md:hidden">
<div className="flex items-center justify-between gap-4">
{renderBooleanLabel()}
<div className="flex items-center gap-2">{children}</div>
</div>
{renderDescription()}
{renderDocsLink()}
</div>
<div className={cn("hidden md:grid", SPLIT_ROW_CLASS_NAME)}>
<div className="space-y-0.5">
{renderBooleanLabel()}
{renderDescription()}
{renderDocsLink()}
</div>
<div className="w-full max-w-2xl">
<div className="flex items-center gap-2">{children}</div>
</div>
</div>
</>
);
const renderBooleanInlineLayout = () => (
<div className="flex w-full items-center justify-between gap-4">
<div className="space-y-0.5">
{renderBooleanLabel()}
{renderDescription()}
{renderDocsLink()}
</div>
<div className="flex items-center gap-2">{children}</div>
</div>
);
const renderSplitValueLayout = () => (
<div className={cn(SPLIT_ROW_CLASS_NAME, "space-y-1.5 md:space-y-3")}>
<div className="space-y-1.5">
{renderSplitLabel()}
{renderDescription("hidden md:block")}
{renderDocsLink("hidden md:flex")}
</div>
<div className="w-full max-w-2xl space-y-1">
{children}
{renderDescription("md:hidden")}
{renderDocsLink("md:hidden")}
</div>
</div>
);
const renderDefaultValueLayout = () => (
<>
{children}
{renderDescription()}
{renderDocsLink()}
</>
);
const renderFieldLayout = () => {
if (isBoolean) {
return useSplitBooleanLayout
? renderBooleanSplitLayout()
: renderBooleanInlineLayout();
}
if (useSplitLayout) {
return renderSplitValueLayout();
}
return renderDefaultValueLayout();
};
return (
<WrapIfAdditionalTemplate
classNames={classNames}
style={style}
disabled={disabled}
id={id}
label={label}
displayLabel={displayLabel}
onKeyRename={onKeyRename}
onKeyRenameBlur={onKeyRenameBlur}
onRemoveProperty={onRemoveProperty}
rawDescription={rawDescription}
readonly={readonly}
required={required}
schema={schema}
uiSchema={uiSchema}
registry={registry}
rawErrors={rawErrors}
hideError={false}
>
<div className="flex flex-col space-y-6">
{beforeContent}
<div className={cn("space-y-1")} data-field-id={translationPath}>
{renderStandardLabel()}
{renderFieldLayout()}
{errors}
{help}
</div>
{afterContent}
</div>
</WrapIfAdditionalTemplate>
);
}
@@ -0,0 +1,45 @@
// Custom MultiSchemaFieldTemplate to handle anyOf [Type, null] fields
// Renders simple nullable types as single inputs instead of dropdowns
import {
MultiSchemaFieldTemplateProps,
StrictRJSFSchema,
FormContextType,
UiSchema,
} from "@rjsf/utils";
import { isNullableUnionSchema } from "../fields/nullableUtils";
/**
* Custom MultiSchemaFieldTemplate that:
* 1. Renders simple anyOf [Type, null] fields as single inputs
* 2. Falls back to default behavior for complex types
*/
export function MultiSchemaFieldTemplate<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T = any,
S extends StrictRJSFSchema = StrictRJSFSchema,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
F extends FormContextType = any,
>(props: MultiSchemaFieldTemplateProps<T, S, F>): JSX.Element {
const { schema, selector, optionSchemaField, uiSchema } = props;
const uiOptions = uiSchema?.["ui:options"] as
| UiSchema["ui:options"]
| undefined;
const suppressMultiSchema = uiOptions?.suppressMultiSchema === true;
// Check if this is a simple nullable field that should be handled specially
if (isNullableUnionSchema(schema) || suppressMultiSchema) {
// For simple nullable fields, just render the field directly without the dropdown selector
// This handles the case where empty input = null
return <>{optionSchemaField}</>;
}
// For all other cases, render with both selector and field (default MultiSchemaField behavior)
return (
<>
{selector}
{optionSchemaField}
</>
);
}
@@ -0,0 +1,503 @@
// Object Field Template - renders nested object fields with i18n support
import type { ObjectFieldTemplateProps } from "@rjsf/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Children, useState, useEffect, useRef } from "react";
import type { ReactNode } from "react";
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { getTranslatedLabel } from "@/utils/i18n";
import { requiresRestartForFieldPath } from "@/utils/configUtil";
import { ConfigFormContext } from "@/types/configForm";
import {
buildTranslationPath,
getDomainFromNamespace,
getFilterObjectLabel,
humanizeKey,
isSubtreeModified,
} from "../utils";
import get from "lodash/get";
import { AddPropertyButton, AdvancedCollapsible } from "../components";
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const {
title,
description,
properties,
uiSchema,
registry,
schema,
onAddProperty,
formData,
disabled,
readonly,
} = props;
const formContext = registry?.formContext as ConfigFormContext | undefined;
// Check if this is a root-level object
const isRoot = registry?.rootSchema === schema;
const overrides = formContext?.overrides;
const baselineFormData = formContext?.baselineFormData;
const hiddenFields = formContext?.hiddenFields;
const fieldPath = props.fieldPathId.path;
const restartRequired = formContext?.restartRequired;
const defaultRequiresRestart = formContext?.requiresRestart ?? true;
// Strip fields from an object that should be excluded from modification
// detection: fields listed in hiddenFields (stripped from baseline by
// sanitizeSectionData) and fields with ui:widget=hidden in uiSchema
// (managed by custom components, not the standard form).
const stripExcludedFields = (
data: unknown,
path: Array<string | number>,
): unknown => {
if (
!data ||
typeof data !== "object" ||
Array.isArray(data) ||
data === null
) {
return data;
}
const result = { ...(data as Record<string, unknown>) };
const pathStrings = path.map(String);
// Strip hiddenFields that match the current path prefix
if (hiddenFields) {
for (const hidden of hiddenFields) {
const parts = hidden.split(".");
if (
parts.length === pathStrings.length + 1 &&
pathStrings.every((s, i) => s === parts[i])
) {
delete result[parts[parts.length - 1]];
}
}
}
// Strip ui:widget=hidden fields from uiSchema at this level
if (uiSchema) {
// Navigate to the uiSchema subtree matching the relative path
let subUiSchema = uiSchema;
const relativePath = path.slice(fieldPath.length);
for (const segment of relativePath) {
if (
typeof segment === "string" &&
subUiSchema &&
typeof subUiSchema[segment] === "object"
) {
subUiSchema = subUiSchema[segment] as typeof uiSchema;
} else {
subUiSchema = undefined as unknown as typeof uiSchema;
break;
}
}
if (subUiSchema && typeof subUiSchema === "object") {
for (const [key, propSchema] of Object.entries(subUiSchema)) {
if (
!key.startsWith("ui:") &&
typeof propSchema === "object" &&
propSchema !== null &&
(propSchema as Record<string, unknown>)["ui:widget"] === "hidden"
) {
delete result[key];
}
}
}
}
return result;
};
// Use props.formData (always up-to-date from RJSF) rather than
// formContext.formData which can be stale in parent templates.
const checkSubtreeModified = (path: Array<string | number>): boolean => {
// Compute relative path from this object's fieldPath to get the
// value from props.formData (which represents this object's data)
const relativePath = path.slice(fieldPath.length);
let currentValue =
relativePath.length > 0 ? get(formData, relativePath) : formData;
// Strip hidden/excluded fields from the RJSF data before comparing
// against the baseline (which already has these stripped)
currentValue = stripExcludedFields(currentValue, path);
let baselineValue =
path.length > 0 ? get(baselineFormData, path) : baselineFormData;
// Also strip hidden/excluded fields from the baseline so that fields
// managed by custom components (e.g. required_zones with ui:widget=hidden)
// don't cause false modification detection.
baselineValue = stripExcludedFields(baselineValue, path);
return isSubtreeModified(
currentValue,
baselineValue,
overrides,
path,
formContext?.formData,
);
};
const hasModifiedDescendants = checkSubtreeModified(fieldPath);
const [isOpen, setIsOpen] = useState(hasModifiedDescendants);
const resetKey = `${formContext?.level ?? "global"}::${
formContext?.cameraName ?? "global"
}`;
const lastResetKeyRef = useRef<string | null>(null);
// Auto-expand collapsible when modifications are detected
useEffect(() => {
if (hasModifiedDescendants) {
setIsOpen(true);
}
}, [hasModifiedDescendants]);
const isCameraLevel = formContext?.level === "camera";
const effectiveNamespace = isCameraLevel ? "config/cameras" : "config/global";
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
const { t, i18n } = useTranslation([
effectiveNamespace,
"config/groups",
"views/settings",
"common",
]);
const objectRequiresRestart = requiresRestartForFieldPath(
fieldPath,
restartRequired,
defaultRequiresRestart,
);
const domain = getDomainFromNamespace(formContext?.i18nNamespace);
const groupDefinitions =
(uiSchema?.["ui:groups"] as Record<string, string[]> | undefined) || {};
const disableNestedCard =
uiSchema?.["ui:options"]?.disableNestedCard === true;
const isHiddenProp = (prop: (typeof properties)[number]) =>
prop.content.props.uiSchema?.["ui:widget"] === "hidden";
const visibleProps = properties.filter((prop) => !isHiddenProp(prop));
// Check for advanced section grouping
const advancedProps = visibleProps.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
);
const regularProps = visibleProps.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
);
const hasModifiedAdvanced = advancedProps.some((prop) =>
checkSubtreeModified([...fieldPath, prop.name]),
);
const [showAdvanced, setShowAdvanced] = useState(hasModifiedAdvanced);
// Auto-expand advanced section when modifications are detected
useEffect(() => {
if (hasModifiedAdvanced) {
setShowAdvanced(true);
}
}, [hasModifiedAdvanced]);
useEffect(() => {
if (lastResetKeyRef.current !== resetKey) {
lastResetKeyRef.current = resetKey;
setIsOpen(hasModifiedDescendants);
setShowAdvanced(hasModifiedAdvanced);
}
}, [resetKey, hasModifiedDescendants, hasModifiedAdvanced]);
const { children } = props as ObjectFieldTemplateProps & {
children?: ReactNode;
};
const hasCustomChildren = Children.count(children) > 0;
// Get the full translation path from the field path
const fieldPathId = (
props as { fieldPathId?: { path?: (string | number)[] } }
).fieldPathId;
let propertyName: string | undefined;
let translationPath: string | undefined;
const path = fieldPathId?.path;
const filterObjectLabel = path ? getFilterObjectLabel(path) : undefined;
const translatedFilterLabel = filterObjectLabel
? getTranslatedLabel(filterObjectLabel, "object")
: undefined;
if (path) {
translationPath = buildTranslationPath(
path,
sectionI18nPrefix,
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];
if (typeof segment === "string") {
propertyName = segment;
break;
}
}
}
// Try i18n translation, fall back to schema or original values
const i18nNs = effectiveNamespace;
let inferredLabel: string | undefined;
if (i18nNs && translationPath) {
const prefixedLabelKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.label`
: undefined;
const labelKey = `${translationPath}.label`;
if (prefixedLabelKey && i18n.exists(prefixedLabelKey, { ns: i18nNs })) {
inferredLabel = t(prefixedLabelKey, { ns: i18nNs });
} else if (i18n.exists(labelKey, { ns: i18nNs })) {
inferredLabel = t(labelKey, { ns: i18nNs });
}
}
if (!inferredLabel && translatedFilterLabel) {
inferredLabel = translatedFilterLabel;
}
const schemaTitle = schema?.title;
const fallbackLabel =
title ||
schemaTitle ||
(propertyName ? humanizeKey(propertyName) : undefined);
inferredLabel = inferredLabel ?? fallbackLabel;
let inferredDescription: string | undefined;
if (i18nNs && translationPath) {
const prefixedDescriptionKey =
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
? `${sectionI18nPrefix}.${translationPath}.description`
: undefined;
const descriptionKey = `${translationPath}.description`;
if (
prefixedDescriptionKey &&
i18n.exists(prefixedDescriptionKey, { ns: i18nNs })
) {
inferredDescription = t(prefixedDescriptionKey, { ns: i18nNs });
} else if (i18n.exists(descriptionKey, { ns: i18nNs })) {
inferredDescription = t(descriptionKey, { ns: i18nNs });
}
}
const schemaDescription = schema?.description;
const fallbackDescription =
(typeof description === "string" ? description : undefined) ||
schemaDescription;
inferredDescription = inferredDescription ?? fallbackDescription;
const renderGroupedFields = (items: (typeof properties)[number][]) => {
if (!items.length) {
return null;
}
const grouped = new Set<string>();
const groups = Object.entries(groupDefinitions)
.map(([groupKey, fields]) => {
const ordered = fields
.map((field) => items.find((item) => item.name === field))
.filter(Boolean) as (typeof properties)[number][];
if (ordered.length === 0) {
return null;
}
ordered.forEach((item) => grouped.add(item.name));
const label = domain
? t(`${sectionI18nPrefix}.${domain}.${groupKey}`, {
ns: "config/groups",
defaultValue: humanizeKey(groupKey),
})
: t(`groups.${groupKey}`, {
defaultValue: humanizeKey(groupKey),
});
return {
key: groupKey,
label,
items: ordered,
};
})
.filter(Boolean) as Array<{
key: string;
label: string;
items: (typeof properties)[number][];
}>;
const ungrouped = items.filter((item) => !grouped.has(item.name));
const isObjectLikeField = (item: (typeof properties)[number]) => {
const fieldSchema = item.content.props.schema as
| { type?: string | string[] }
| undefined;
return fieldSchema?.type === "object";
};
return (
<div className="space-y-6">
{groups.map((group) => (
<div
key={group.key}
className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4"
>
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
{group.label}
</div>
<div className="space-y-6">
{group.items.map((element) => (
<div key={element.name}>{element.content}</div>
))}
</div>
</div>
))}
{ungrouped.length > 0 && (
<div className={cn("space-y-6", groups.length > 0 && "pt-2")}>
{ungrouped.map((element) => (
<div
key={element.name}
className={cn(
groups.length > 0 && !isObjectLikeField(element) && "px-4",
)}
>
{element.content}
</div>
))}
</div>
)}
</div>
);
};
// Root level renders children directly
if (isRoot) {
return (
<div className="space-y-6">
{hasCustomChildren ? (
children
) : (
<>
{renderGroupedFields(regularProps)}
<AddPropertyButton
onAddProperty={onAddProperty}
schema={schema}
uiSchema={uiSchema}
formData={formData}
disabled={disabled}
readonly={readonly}
/>
<AdvancedCollapsible
count={advancedProps.length}
open={showAdvanced}
onOpenChange={setShowAdvanced}
isRoot
>
{renderGroupedFields(advancedProps)}
</AdvancedCollapsible>
</>
)}
</div>
);
}
if (disableNestedCard) {
return (
<div className="space-y-4">
{hasCustomChildren ? (
children
) : (
<>
{renderGroupedFields(regularProps)}
<AddPropertyButton
onAddProperty={onAddProperty}
schema={schema}
uiSchema={uiSchema}
formData={formData}
disabled={disabled}
readonly={readonly}
/>
<AdvancedCollapsible
count={advancedProps.length}
open={showAdvanced}
onOpenChange={setShowAdvanced}
>
{renderGroupedFields(advancedProps)}
</AdvancedCollapsible>
</>
)}
</div>
);
}
// Nested objects render as collapsible cards
return (
<Card className="w-full">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
<div className="flex items-center justify-between">
<div>
<CardTitle
className={cn(
"flex items-center text-sm",
hasModifiedDescendants && "text-danger",
)}
>
{inferredLabel}
{objectRequiresRestart && (
<RestartRequiredIndicator className="ml-2" />
)}
</CardTitle>
{inferredDescription && (
<p className="mt-1 text-xs text-muted-foreground">
{inferredDescription}
</p>
)}
</div>
{isOpen ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-6 p-4 pt-0">
{hasCustomChildren ? (
children
) : (
<>
{renderGroupedFields(regularProps)}
<AddPropertyButton
onAddProperty={onAddProperty}
schema={schema}
uiSchema={uiSchema}
formData={formData}
disabled={disabled}
readonly={readonly}
/>
<AdvancedCollapsible
count={advancedProps.length}
open={showAdvanced}
onOpenChange={setShowAdvanced}
>
{renderGroupedFields(advancedProps)}
</AdvancedCollapsible>
</>
)}
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
@@ -0,0 +1,17 @@
// Title Field Template
import type { TitleFieldProps } from "@rjsf/utils";
export function TitleFieldTemplate(props: TitleFieldProps) {
const { title, id, required } = props;
if (!title) {
return null;
}
return (
<h3 id={id} className="text-lg font-semibold">
{title}
{required && <span className="ml-1 text-destructive">*</span>}
</h3>
);
}
@@ -0,0 +1,123 @@
import {
ADDITIONAL_PROPERTY_FLAG,
FormContextType,
getUiOptions,
RJSFSchema,
StrictRJSFSchema,
WrapIfAdditionalTemplateProps,
} from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { LuTrash2 } from "react-icons/lu";
export function WrapIfAdditionalTemplate<
T = unknown,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = FormContextType,
>(props: WrapIfAdditionalTemplateProps<T, S, F>) {
const {
classNames,
style,
children,
disabled,
id,
label,
displayLabel,
onRemoveProperty,
onKeyRenameBlur,
readonly,
required,
schema,
uiSchema,
} = props;
const { t } = useTranslation(["views/settings"]);
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
if (!additional) {
return (
<div className={classNames} style={style}>
{children}
</div>
);
}
const keyId = `${id}-key`;
const keyLabel = t("configForm.additionalProperties.keyLabel", {
ns: "views/settings",
});
const valueLabel = t("configForm.additionalProperties.valueLabel", {
ns: "views/settings",
});
const keyPlaceholder = t("configForm.additionalProperties.keyPlaceholder", {
ns: "views/settings",
});
const removeLabel = t("configForm.additionalProperties.remove", {
ns: "views/settings",
});
const uiOptions = getUiOptions(uiSchema);
const keyIsReadonly = uiOptions.additionalPropertyKeyReadonly === true;
return (
<div
className={cn("grid grid-cols-12 items-start gap-2", classNames)}
style={style}
>
{!keyIsReadonly && (
<div className="col-span-12 space-y-2 md:col-span-2">
{displayLabel && <Label htmlFor={keyId}>{keyLabel}</Label>}
{keyIsReadonly ? (
<div
id={keyId}
className="flex items-center text-sm text-muted-foreground"
>
{label}
</div>
) : (
<Input
id={keyId}
name={keyId}
required={required}
defaultValue={label}
placeholder={keyPlaceholder}
disabled={disabled || readonly}
onBlur={!readonly ? onKeyRenameBlur : undefined}
/>
)}
</div>
)}
<div
className={cn(
"col-span-12 space-y-2",
!keyIsReadonly && "md:col-span-9",
)}
>
{!keyIsReadonly && displayLabel && (
<Label htmlFor={id}>{valueLabel}</Label>
)}
<div className="min-w-0">{children}</div>
</div>
{!keyIsReadonly && (
<div className="col-span-12 flex items-center md:col-span-1 md:justify-center">
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemoveProperty}
disabled={disabled || readonly}
aria-label={removeLabel}
title={removeLabel}
>
<LuTrash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
}
export default WrapIfAdditionalTemplate;
@@ -0,0 +1,37 @@
import { cn } from "@/lib/utils";
const FIELD_SIZE_CLASS_MAP = {
xs: "max-w-xs",
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-2xl",
full: "max-w-full",
} as const;
export type FieldSizeOption = keyof typeof FIELD_SIZE_CLASS_MAP;
type FieldSizingOptions = {
size?: FieldSizeOption;
maxWidthClassName?: string;
className?: string;
};
export function getSizedFieldClassName(
options: unknown,
defaultSize: FieldSizeOption = "lg",
) {
const sizingOptions =
typeof options === "object" && options !== null
? (options as FieldSizingOptions)
: undefined;
const sizeClass =
FIELD_SIZE_CLASS_MAP[sizingOptions?.size ?? defaultSize] ??
FIELD_SIZE_CLASS_MAP[defaultSize];
return cn(
"w-full",
sizingOptions?.maxWidthClassName ?? sizeClass,
sizingOptions?.className,
);
}
@@ -0,0 +1,182 @@
/**
* Shared i18n utilities for config form templates and fields.
*
* These functions handle translation key path building and label normalization
* for RJSF form fields.
*/
import type { ConfigFormContext } from "@/types/configForm";
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.
*
* @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
* @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
const stringSegments = segments.filter(
(segment): segment is string => typeof segment === "string",
);
// Handle filters section - skip the dynamic filter object name
// Example: filters.person.threshold -> filters.threshold
const filtersIndex = stringSegments.indexOf("filters");
if (filtersIndex !== -1 && stringSegments.length > filtersIndex + 2) {
const normalized = [
...stringSegments.slice(0, filtersIndex + 1),
...stringSegments.slice(filtersIndex + 2),
];
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(".");
}
/**
* Extract the filter object label from a path containing "filters" segment.
* Returns the segment immediately after "filters".
*
* @param pathSegments Array of path segments
* @returns The filter object label or undefined if not found
*
* @example
* getFilterObjectLabel(["filters", "person", "threshold"]) => "person"
* getFilterObjectLabel(["detect", "enabled"]) => undefined
*/
export function getFilterObjectLabel(
pathSegments: Array<string | number>,
): string | undefined {
const filtersIndex = pathSegments.indexOf("filters");
if (filtersIndex === -1 || pathSegments.length <= filtersIndex + 1) {
return undefined;
}
const objectLabel = pathSegments[filtersIndex + 1];
return typeof objectLabel === "string" && objectLabel.length > 0
? objectLabel
: undefined;
}
/**
* Convert snake_case string to Title Case with spaces.
* Useful for generating human-readable labels from schema property names.
*
* @param value The snake_case string to convert
* @returns Title Case string
*
* @example
* humanizeKey("detect_fps") => "Detect Fps"
* humanizeKey("min_initialized") => "Min Initialized"
*/
export function humanizeKey(value: string): string {
return value
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
}
/**
* Extract domain name from an i18n namespace string.
* Handles config/* namespace format by stripping the prefix.
*
* @param ns The i18n namespace (e.g., "config/audio", "config/global")
* @returns The domain portion (e.g., "audio", "global") or empty string
*
* @example
* getDomainFromNamespace("config/audio") => "audio"
* getDomainFromNamespace("common") => ""
*/
export function getDomainFromNamespace(ns?: string): string {
if (!ns || !ns.startsWith("config/")) return "";
return ns.replace("config/", "");
}
@@ -0,0 +1,18 @@
/**
* Config form theme utilities
*/
export {
buildTranslationPath,
getFilterObjectLabel,
humanizeKey,
getDomainFromNamespace,
} from "./i18n";
export { getOverrideAtPath, hasOverrideAtPath } from "./overrides";
export {
deepNormalizeValue,
normalizeFieldValue,
isSubtreeModified,
} from "./overrides";
export { getSizedFieldClassName } from "./fieldSizing";
@@ -0,0 +1,128 @@
import get from "lodash/get";
import isEqual from "lodash/isEqual";
import { isJsonObject } from "@/lib/utils";
import type { JsonValue } from "@/types/configForm";
export const getOverrideAtPath = (
overrides: JsonValue | undefined,
path: Array<string | number>,
) => {
if (overrides === undefined || overrides === null) {
return undefined;
}
if (isJsonObject(overrides) || Array.isArray(overrides)) {
return get(overrides, path);
}
return path.length === 0 ? overrides : undefined;
};
export const normalizeOverridePath = (
path: Array<string | number>,
data: JsonValue | undefined,
) => {
if (data === undefined || data === null) {
return path;
}
const normalized: Array<string | number> = [];
let cursor: JsonValue | undefined = data;
for (const segment of path) {
if (typeof segment === "number") {
if (Array.isArray(cursor)) {
normalized.push(segment);
cursor = cursor[segment] as JsonValue | undefined;
}
continue;
}
normalized.push(segment);
if (isJsonObject(cursor) || Array.isArray(cursor)) {
cursor = (cursor as Record<string, JsonValue>)[segment];
} else {
cursor = undefined;
}
}
return normalized;
};
export const hasOverrideAtPath = (
overrides: JsonValue | undefined,
path: Array<string | number>,
contextData?: JsonValue,
) => {
const normalizedPath = contextData
? normalizeOverridePath(path, contextData)
: path;
const value = getOverrideAtPath(overrides, normalizedPath);
if (value !== undefined) {
return true;
}
const shouldFallback =
normalizedPath.length !== path.length ||
normalizedPath.some((segment, index) => segment !== path[index]);
if (!shouldFallback) {
return false;
}
return getOverrideAtPath(overrides, path) !== undefined;
};
/**
* Deep normalization for form data comparison. Strips null, undefined,
* and empty-string values from objects and arrays so that RJSF-injected
* schema defaults (e.g., `mask: null`) don't cause false positives
* against a baseline that lacks those keys.
*/
export const deepNormalizeValue = (value: unknown): unknown => {
if (value === null || value === undefined || value === "") return undefined;
if (Array.isArray(value)) return value.map(deepNormalizeValue);
if (typeof value === "object" && value !== null) {
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const normalized = deepNormalizeValue(v);
if (normalized !== undefined) {
result[k] = normalized;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
return value;
};
/**
* Shallow normalization for individual field values.
* Treats null and empty-string as equivalent to undefined.
*/
export const normalizeFieldValue = (value: unknown): unknown =>
value === null || value === "" ? undefined : value;
/**
* Check whether a subtree of form data has been modified relative to
* the baseline. Uses deep normalization to ignore RJSF-injected null/empty
* schema defaults.
*
* @param currentData - The current value at the subtree (from props.formData)
* @param baselineData - The baseline value at the subtree (from formContext.baselineFormData)
* @param overrides - Fallback: the overrides object from formContext
* @param path - The full field path for the fallback override check
* @param contextData - The full form data for normalizing the override path
*/
export const isSubtreeModified = (
currentData: unknown,
baselineData: unknown,
overrides: JsonValue | undefined,
path: Array<string | number>,
contextData?: JsonValue,
): boolean => {
if (baselineData !== undefined || currentData !== undefined) {
return !isEqual(
deepNormalizeValue(currentData),
deepNormalizeValue(baselineData),
);
}
return hasOverrideAtPath(overrides, path, contextData);
};
@@ -0,0 +1,36 @@
// Widget that displays an array as a concatenated text string
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { useCallback } from "react";
export function ArrayAsTextWidget(props: WidgetProps) {
const { value, onChange, disabled, readonly, placeholder } = props;
// Convert array or string to text
let textValue = "";
if (typeof value === "string" && value.length > 0) {
textValue = value;
} else if (Array.isArray(value) && value.length > 0) {
textValue = value.join(" ");
}
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const newText = event.target.value;
// Convert space-separated string back to array
const newArray = newText.trim() ? newText.trim().split(/\s+/) : [];
onChange(newArray);
},
[onChange],
);
return (
<Input
value={textValue}
onChange={handleChange}
disabled={disabled}
readOnly={readonly}
placeholder={placeholder}
/>
);
}
@@ -0,0 +1,101 @@
// Audio Label Switches Widget - For selecting audio labels via switches
import type { WidgetProps } from "@rjsf/utils";
import { useCallback, useMemo } from "react";
import useSWR from "swr";
import { SwitchesWidget } from "./SwitchesWidget";
import type { FormContext } from "./SwitchesWidget";
import { getTranslatedLabel } from "@/utils/i18n";
import { JsonObject } from "@/types/configForm";
function getEnabledAudioLabels(context: FormContext): string[] {
let cameraLabels: string[] = [];
let globalLabels: string[] = [];
if (context) {
// context.cameraValue and context.globalValue should be the entire audio section
if (
context.cameraValue &&
typeof context.cameraValue === "object" &&
!Array.isArray(context.cameraValue)
) {
const listenValue = (context.cameraValue as JsonObject).listen;
if (Array.isArray(listenValue)) {
cameraLabels = listenValue.filter(
(item): item is string => typeof item === "string",
);
}
}
if (
context.globalValue &&
typeof context.globalValue === "object" &&
!Array.isArray(context.globalValue)
) {
const globalListenValue = (context.globalValue as JsonObject).listen;
if (Array.isArray(globalListenValue)) {
globalLabels = globalListenValue.filter(
(item): item is string => typeof item === "string",
);
}
}
}
const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels;
return [...sourceLabels].sort();
}
function getAudioLabelDisplayName(label: string): string {
return getTranslatedLabel(label, "audio");
}
export function AudioLabelSwitchesWidget(props: WidgetProps) {
const { data: audioLabels } = useSWR<Record<string, string>>("/audio_labels");
const allLabels = useMemo(() => {
if (!audioLabels) {
return [];
}
const labelSet = new Set<string>();
Object.values(audioLabels).forEach((label) => {
if (typeof label !== "string") {
return;
}
const normalized = label.trim();
if (normalized) {
labelSet.add(normalized);
}
});
return [...labelSet].sort();
}, [audioLabels]);
const getEntities = useCallback(
(context: FormContext) => {
const enabledLabels = getEnabledAudioLabels(context);
if (allLabels.length === 0) {
return enabledLabels;
}
const combinedLabels = new Set([...allLabels, ...enabledLabels]);
return [...combinedLabels].sort();
},
[allLabels],
);
return (
<SwitchesWidget
{...props}
options={{
...props.options,
getEntities,
getDisplayLabel: getAudioLabelDisplayName,
i18nKey: "audioLabels",
listClassName:
"max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
enableSearch: true,
}}
/>
);
}
@@ -0,0 +1,202 @@
import type { WidgetProps } from "@rjsf/utils";
import useSWR from "swr";
import { useMemo, useState, type FocusEvent } from "react";
import { useTranslation } from "react-i18next";
import { LuEye, LuEyeOff } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { ConfigFormContext } from "@/types/configForm";
import { cn } from "@/lib/utils";
import { getSizedFieldClassName } from "../utils";
type RawPathsResponse = {
cameras?: Record<
string,
{
ffmpeg?: {
inputs?: Array<{
path?: string;
}>;
};
}
>;
};
const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i;
const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i;
const getInputIndexFromWidgetId = (id: string): number | undefined => {
const match = id.match(/_inputs_(\d+)_path$/);
if (!match) {
return undefined;
}
const index = Number(match[1]);
return Number.isNaN(index) ? undefined : index;
};
const isMaskedPath = (value: string): boolean =>
MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value);
const hasCredentials = (value: string): boolean => {
if (!value) {
return false;
}
if (isMaskedPath(value)) {
return true;
}
try {
const parsed = new URL(value);
if (parsed.username || parsed.password) {
return true;
}
return (
parsed.searchParams.has("user") && parsed.searchParams.has("password")
);
} catch {
return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value);
}
};
const maskCredentials = (value: string): string => {
if (!value) {
return value;
}
const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@");
return maskedAuth
.replace(/([?&]user=)[^&]*/gi, "$1*")
.replace(/([?&]password=)[^&]*/gi, "$1*");
};
export function CameraPathWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
const { t } = useTranslation(["common", "views/settings"]);
const [showCredentials, setShowCredentials] = useState(false);
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
const isCameraLevel = formContext?.level === "camera";
const cameraName = formContext?.cameraName;
const inputIndex = useMemo(() => getInputIndexFromWidgetId(id), [id]);
const shouldFetchRawPaths =
isCameraLevel && !!cameraName && inputIndex !== undefined;
const { data: rawPaths } = useSWR<RawPathsResponse>(
shouldFetchRawPaths ? "config/raw_paths" : null,
);
const rawPath = useMemo(() => {
if (!cameraName || inputIndex === undefined) {
return undefined;
}
const path =
rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs?.[inputIndex]?.path;
return typeof path === "string" ? path : undefined;
}, [cameraName, inputIndex, rawPaths]);
const rawValue = typeof value === "string" ? value : "";
const resolvedValue =
isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? "");
const canReveal =
hasCredentials(resolvedValue) && !isMaskedPath(resolvedValue);
const canToggle = canReveal || isMaskedPath(rawValue);
const isMaskedView = canToggle && !showCredentials;
const displayValue = isMaskedView
? maskCredentials(resolvedValue)
: resolvedValue;
const isNullable = Array.isArray(schema.type)
? schema.type.includes("null")
: false;
const fieldClassName = getSizedFieldClassName(options, "xs");
const uriLabel = t("cameraWizard.step3.url", {
ns: "views/settings",
defaultValue: schema.title,
});
const toggleLabel = showCredentials
? t("label.hide", { ns: "common", item: uriLabel })
: t("label.show", { ns: "common", item: uriLabel });
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
if (isMaskedView && canReveal) {
setShowCredentials(true);
onFocus(id, resolvedValue);
return;
}
onFocus(id, event.target.value);
};
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
if (canToggle) {
setShowCredentials(false);
}
onBlur(id, event.target.value);
};
return (
<div className={cn("relative", fieldClassName)}>
<Input
id={id}
className={cn("text-md", canToggle ? "pr-10" : undefined)}
type="text"
value={displayValue}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
onChange={(e) =>
onChange(
e.target.value === ""
? isNullable
? null
: undefined
: e.target.value,
)
}
onBlur={handleBlur}
onFocus={handleFocus}
aria-label={schema.title}
/>
{canToggle ? (
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowCredentials((previous) => !previous)}
disabled={disabled || (!canReveal && !showCredentials)}
aria-label={toggleLabel}
title={toggleLabel}
>
{showCredentials ? (
<LuEyeOff className="h-4 w-4" />
) : (
<LuEye className="h-4 w-4" />
)}
</Button>
) : null}
</div>
);
}
@@ -0,0 +1,17 @@
// Checkbox Widget - maps to shadcn/ui Checkbox
import type { WidgetProps } from "@rjsf/utils";
import { Checkbox } from "@/components/ui/checkbox";
export function CheckboxWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, label, schema } = props;
return (
<Checkbox
id={id}
checked={typeof value === "undefined" ? false : value}
disabled={disabled || readonly}
onCheckedChange={(checked) => onChange(checked)}
aria-label={label || schema.title || "Checkbox"}
/>
);
}
@@ -0,0 +1,53 @@
// Color Widget - For RGB color objects
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useMemo, useCallback } from "react";
interface RGBColor {
red: number;
green: number;
blue: number;
}
export function ColorWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange } = props;
// Convert object to hex for color picker
const hexValue = useMemo(() => {
if (!value || typeof value !== "object") {
return "#ffffff";
}
const { red = 255, green = 255, blue = 255 } = value as RGBColor;
return `#${red.toString(16).padStart(2, "0")}${green.toString(16).padStart(2, "0")}${blue.toString(16).padStart(2, "0")}`;
}, [value]);
const handleColorChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const hex = e.target.value;
const red = parseInt(hex.slice(1, 3), 16);
const green = parseInt(hex.slice(3, 5), 16);
const blue = parseInt(hex.slice(5, 7), 16);
onChange({ red, green, blue });
},
[onChange],
);
return (
<div className="flex items-center gap-3">
<Input
id={id}
type="color"
value={hexValue}
disabled={disabled || readonly}
onChange={handleColorChange}
className="h-10 w-14 cursor-pointer p-1"
/>
<div className="flex gap-2 text-sm text-muted-foreground">
<Label>R: {(value as RGBColor)?.red ?? 255}</Label>
<Label>G: {(value as RGBColor)?.green ?? 255}</Label>
<Label>B: {(value as RGBColor)?.blue ?? 255}</Label>
</div>
</div>
);
}
@@ -0,0 +1,344 @@
import type { WidgetProps } from "@rjsf/utils";
import useSWR from "swr";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { ConfigFormContext } from "@/types/configForm";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
type FfmpegPresetResponse = {
hwaccel_args: string[];
input_args: string[];
output_args: {
record: string[];
detect: string[];
};
};
type FfmpegArgsMode = "preset" | "manual" | "inherit";
type PresetField =
| "hwaccel_args"
| "input_args"
| "output_args.record"
| "output_args.detect";
const getPresetOptions = (
data: FfmpegPresetResponse | undefined,
field: PresetField | undefined,
): string[] => {
if (!data || !field) {
return [];
}
if (field === "hwaccel_args") {
return data.hwaccel_args;
}
if (field === "input_args") {
return data.input_args;
}
if (field.startsWith("output_args.")) {
const key = field.split(".")[1] as "record" | "detect";
return data.output_args?.[key] ?? [];
}
return [];
};
const resolveMode = (
value: unknown,
presets: string[],
defaultMode: FfmpegArgsMode,
allowInherit: boolean,
): FfmpegArgsMode => {
if (allowInherit && (value === null || value === undefined)) {
return "inherit";
}
if (allowInherit && Array.isArray(value) && value.length === 0) {
return "inherit";
}
if (Array.isArray(value)) {
return "manual";
}
if (typeof value === "string") {
if (presets.length === 0) {
return defaultMode;
}
return presets.includes(value) ? "preset" : "manual";
}
return defaultMode;
};
const normalizeManualText = (value: unknown): string => {
if (Array.isArray(value)) {
return value.join(" ");
}
if (typeof value === "string") {
return value;
}
return "";
};
export function FfmpegArgsWidget(props: WidgetProps) {
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
const isCameraLevel = formContext?.level === "camera";
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
const { t, i18n } = useTranslation([
effectiveNamespace || i18nNamespace || "common",
i18nNamespace || "common",
"views/settings",
]);
const {
value,
onChange,
disabled,
readonly,
options,
placeholder,
schema,
id,
} = props;
const presetField = options?.ffmpegPresetField as PresetField | undefined;
const allowInherit = options?.allowInherit === true;
const hideDescription = options?.hideDescription === true;
const useSplitLayout = options?.splitLayout !== false;
const { data } = useSWR<FfmpegPresetResponse>("ffmpeg/presets");
const presetOptions = useMemo(
() => getPresetOptions(data, presetField),
[data, presetField],
);
const canUsePresets = presetOptions.length > 0;
const defaultMode: FfmpegArgsMode = canUsePresets ? "preset" : "manual";
const detectedMode = useMemo(
() => resolveMode(value, presetOptions, defaultMode, allowInherit),
[value, presetOptions, defaultMode, allowInherit],
);
const [mode, setMode] = useState<FfmpegArgsMode>(detectedMode);
useEffect(() => {
if (!canUsePresets && detectedMode === "preset") {
setMode("manual");
return;
}
setMode(detectedMode);
}, [canUsePresets, detectedMode]);
const handleModeChange = useCallback(
(nextMode: FfmpegArgsMode) => {
setMode(nextMode);
if (nextMode === "inherit") {
onChange(undefined);
return;
}
if (nextMode === "preset") {
const currentValue = typeof value === "string" ? value : undefined;
const presetValue =
currentValue && presetOptions.includes(currentValue)
? currentValue
: presetOptions[0];
if (presetValue) {
onChange(presetValue);
}
return;
}
if (mode === "preset") {
onChange("");
return;
}
const manualText = normalizeManualText(value);
onChange(manualText);
},
[mode, onChange, presetOptions, value],
);
const handlePresetChange = useCallback(
(preset: string) => {
onChange(preset);
},
[onChange],
);
const handleManualChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const newText = event.target.value;
onChange(newText);
},
[onChange],
);
const manualValue = normalizeManualText(value);
const presetValue =
typeof value === "string" && presetOptions.includes(value) ? value : "";
const fallbackDescriptionKey = useMemo(() => {
if (!presetField) {
return undefined;
}
const isInputScoped = id.includes("_inputs_");
const prefix = isInputScoped ? "ffmpeg.inputs" : "ffmpeg";
if (presetField === "hwaccel_args") {
return `${prefix}.hwaccel_args.description`;
}
if (presetField === "input_args") {
return `${prefix}.input_args.description`;
}
if (presetField === "output_args.record") {
return isInputScoped
? "ffmpeg.inputs.output_args.record.description"
: "ffmpeg.output_args.record.description";
}
if (presetField === "output_args.detect") {
return isInputScoped
? "ffmpeg.inputs.output_args.detect.description"
: "ffmpeg.output_args.detect.description";
}
return undefined;
}, [id, presetField]);
const translatedDescription =
fallbackDescriptionKey &&
effectiveNamespace &&
i18n.exists(fallbackDescriptionKey, { ns: effectiveNamespace })
? t(fallbackDescriptionKey, { ns: effectiveNamespace })
: "";
const fieldDescription =
typeof schema.description === "string" && schema.description.length > 0
? schema.description
: translatedDescription;
return (
<div className="space-y-2">
<RadioGroup
value={mode}
onValueChange={(next) => handleModeChange(next as FfmpegArgsMode)}
className="gap-3"
>
{allowInherit ? (
<div className="flex items-center space-x-2">
<RadioGroupItem
value="inherit"
id={`${id}-inherit`}
disabled={disabled || readonly}
className={
mode === "inherit"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label htmlFor={`${id}-inherit`} className="cursor-pointer text-sm">
{t("configForm.ffmpegArgs.inherit", { ns: "views/settings" })}
</label>
</div>
) : null}
<div className="flex items-center space-x-2">
<RadioGroupItem
value="preset"
id={`${id}-preset`}
disabled={disabled || readonly || !canUsePresets}
className={
mode === "preset"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label htmlFor={`${id}-preset`} className="cursor-pointer text-sm">
{t("configForm.ffmpegArgs.preset", { ns: "views/settings" })}
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="manual"
id={`${id}-manual`}
disabled={disabled || readonly}
className={
mode === "manual"
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label htmlFor={`${id}-manual`} className="cursor-pointer text-sm">
{t("configForm.ffmpegArgs.manual", { ns: "views/settings" })}
</label>
</div>
</RadioGroup>
{mode === "inherit" ? null : mode === "preset" && canUsePresets ? (
<Select
value={presetValue}
onValueChange={handlePresetChange}
disabled={disabled || readonly}
>
<SelectTrigger id={id} className="w-full md:max-w-md">
<SelectValue
placeholder={
placeholder ||
schema.title ||
t("configForm.ffmpegArgs.selectPreset", {
ns: "views/settings",
})
}
/>
</SelectTrigger>
<SelectContent>
{presetOptions.map((preset) => (
<SelectItem key={preset} value={preset}>
{preset}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={id}
value={manualValue}
onChange={handleManualChange}
disabled={disabled || readonly}
placeholder={
placeholder ||
t("configForm.ffmpegArgs.manualPlaceholder", {
ns: "views/settings",
})
}
/>
)}
{!hideDescription && !useSplitLayout && fieldDescription ? (
<p className="text-xs text-muted-foreground">{fieldDescription}</p>
) : null}
</div>
);
}
@@ -0,0 +1,67 @@
import type { WidgetProps } from "@rjsf/utils";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
const INPUT_ROLES = ["detect", "record", "audio"] as const;
function normalizeValue(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string");
}
if (typeof value === "string" && value.trim()) {
return [value.trim()];
}
return [];
}
export function InputRolesWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange } = props;
const { t } = useTranslation(["views/settings"]);
const selectedRoles = useMemo(() => normalizeValue(value), [value]);
const toggleRole = (role: string, enabled: boolean) => {
if (enabled) {
if (!selectedRoles.includes(role)) {
onChange([...selectedRoles, role]);
}
return;
}
onChange(selectedRoles.filter((item) => item !== role));
};
return (
<div className="rounded-lg border border-secondary-highlight bg-background_alt p-2 pr-0 md:max-w-md">
<div className="grid gap-2">
{INPUT_ROLES.map((role) => {
const checked = selectedRoles.includes(role);
const label = t(`configForm.inputRoles.options.${role}`, {
ns: "views/settings",
defaultValue: role,
});
return (
<div
key={role}
className="flex items-center justify-between rounded-md px-3 py-0"
>
<label htmlFor={`${id}-${role}`} className="text-sm">
{label}
</label>
<Switch
id={`${id}-${role}`}
checked={checked}
disabled={disabled || readonly}
onCheckedChange={(enabled) => toggleRole(role, !!enabled)}
/>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,44 @@
// Number Widget - Input with number type
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
export function NumberWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
schema,
options,
} = props;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (val === "") {
onChange(undefined);
} else {
const num =
schema.type === "integer" ? parseInt(val, 10) : parseFloat(val);
onChange(isNaN(num) ? undefined : num);
}
};
return (
<Input
id={id}
type="number"
value={value ?? ""}
disabled={disabled || readonly}
min={schema.minimum}
max={schema.maximum}
step={(options.step as number) || (schema.type === "integer" ? 1 : 0.1)}
onChange={handleChange}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
/>
);
}
@@ -0,0 +1,101 @@
// Object Label Switches Widget - For selecting objects via switches
import { WidgetProps } from "@rjsf/utils";
import { SwitchesWidget } from "./SwitchesWidget";
import { FormContext } from "./SwitchesWidget";
import { getTranslatedLabel } from "@/utils/i18n";
import { FrigateConfig } from "@/types/frigateConfig";
import { JsonObject } from "@/types/configForm";
// Collect labelmap values (human-readable labels) from a labelmap object.
function collectLabelmapLabels(labelmap: unknown, labels: Set<string>) {
if (!labelmap || typeof labelmap !== "object") {
return;
}
Object.values(labelmap as JsonObject).forEach((value) => {
if (typeof value === "string" && value.trim().length > 0) {
labels.add(value);
}
});
}
// Read labelmap labels from the global model and detector models.
function getLabelmapLabels(context: FormContext): string[] {
const labels = new Set<string>();
const fullConfig = context.fullConfig as FrigateConfig | undefined;
if (fullConfig?.model) {
collectLabelmapLabels(fullConfig.model.labelmap, labels);
}
if (fullConfig?.detectors) {
// detectors is a map of detector configs; each may include a model labelmap.
Object.values(fullConfig.detectors).forEach((detector) => {
if (detector?.model?.labelmap) {
collectLabelmapLabels(detector.model.labelmap, labels);
}
});
}
return [...labels];
}
// Build the list of labels for switches (labelmap + configured track list).
function getObjectLabels(context: FormContext): string[] {
const labelmapLabels = getLabelmapLabels(context);
let cameraLabels: string[] = [];
let globalLabels: string[] = [];
if (context) {
// context.cameraValue and context.globalValue should be the entire objects section
if (
context.cameraValue &&
typeof context.cameraValue === "object" &&
!Array.isArray(context.cameraValue)
) {
const trackValue = (context.cameraValue as JsonObject).track;
if (Array.isArray(trackValue)) {
cameraLabels = trackValue.filter(
(item): item is string => typeof item === "string",
);
}
}
if (
context.globalValue &&
typeof context.globalValue === "object" &&
!Array.isArray(context.globalValue)
) {
const globalTrackValue = (context.globalValue as JsonObject).track;
if (Array.isArray(globalTrackValue)) {
globalLabels = globalTrackValue.filter(
(item): item is string => typeof item === "string",
);
}
}
}
const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels;
const combinedLabels = new Set<string>([...labelmapLabels, ...sourceLabels]);
return [...combinedLabels].sort();
}
function getObjectLabelDisplayName(label: string): string {
return getTranslatedLabel(label, "object");
}
export function ObjectLabelSwitchesWidget(props: WidgetProps) {
return (
<SwitchesWidget
{...props}
options={{
...props.options,
getEntities: getObjectLabels,
getDisplayLabel: getObjectLabelDisplayName,
i18nKey: "objectLabels",
listClassName:
"max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
}}
/>
);
}
@@ -0,0 +1,59 @@
// Password Widget - Input with password type
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
import { cn } from "@/lib/utils";
import { getSizedFieldClassName } from "../utils";
export function PasswordWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
const [showPassword, setShowPassword] = useState(false);
const fieldClassName = getSizedFieldClassName(options, "sm");
return (
<div className={cn("relative", fieldClassName)}>
<Input
id={id}
type={showPassword ? "text" : "password"}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
className="w-full pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
disabled={disabled}
>
{showPassword ? (
<LuEyeOff className="h-4 w-4" />
) : (
<LuEye className="h-4 w-4" />
)}
</Button>
</div>
);
}
@@ -0,0 +1,31 @@
// Range Widget - maps to shadcn/ui Slider
import type { WidgetProps } from "@rjsf/utils";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
export function RangeWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, schema, options } = props;
const min = schema.minimum ?? 0;
const max = schema.maximum ?? 100;
const step =
(options.step as number) || (schema.type === "integer" ? 1 : 0.1);
return (
<div className="flex items-center gap-4">
<Slider
id={id}
value={[value ?? min]}
min={min}
max={max}
step={step}
disabled={disabled || readonly}
onValueChange={(vals) => onChange(vals[0])}
className={cn("flex-1", disabled && "opacity-50")}
/>
<span className="w-12 text-right text-sm text-muted-foreground">
{value ?? min}
</span>
</div>
);
}
@@ -0,0 +1,51 @@
// Select Widget - maps to shadcn/ui Select
import type { WidgetProps } from "@rjsf/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getSizedFieldClassName } from "../utils";
export function SelectWidget(props: WidgetProps) {
const {
id,
options,
value,
disabled,
readonly,
onChange,
placeholder,
schema,
} = props;
const { enumOptions = [] } = options;
const fieldClassName = getSizedFieldClassName(options, "sm");
return (
<Select
value={value?.toString() ?? ""}
onValueChange={(val) => {
// Convert back to original type if needed
const enumVal = enumOptions.find(
(opt: { value: unknown }) => opt.value?.toString() === val,
);
onChange(enumVal ? enumVal.value : val);
}}
disabled={disabled || readonly}
>
<SelectTrigger id={id} className={fieldClassName}>
<SelectValue placeholder={placeholder || schema.title || "Select..."} />
</SelectTrigger>
<SelectContent>
{enumOptions.map((option: { value: unknown; label: string }) => (
<SelectItem key={String(option.value)} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,17 @@
// Switch Widget - maps to shadcn/ui Switch
import type { WidgetProps } from "@rjsf/utils";
import { Switch } from "@/components/ui/switch";
export function SwitchWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, label, schema } = props;
return (
<Switch
id={id}
checked={typeof value === "undefined" ? false : value}
disabled={disabled || readonly}
onCheckedChange={(checked) => onChange(checked)}
aria-label={label || schema.title || "Toggle"}
/>
);
}
@@ -0,0 +1,231 @@
// Generic Switches Widget - Reusable component for selecting from any list of entities
import { WidgetProps } from "@rjsf/utils";
import { useMemo, useState } from "react";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { ConfigFormContext } from "@/types/configForm";
import { cn } from "@/lib/utils";
type FormContext = Pick<
ConfigFormContext,
| "cameraValue"
| "globalValue"
| "fullCameraConfig"
| "fullConfig"
| "t"
| "level"
> & {
fullCameraConfig?: CameraConfig;
fullConfig?: FrigateConfig;
};
export type { FormContext };
export type SwitchesWidgetOptions = {
/** Function to extract available entities from context */
getEntities: (context: FormContext) => string[];
/** Function to get display label for an entity (e.g., translate, get friendly name) */
getDisplayLabel?: (entity: string, context?: FormContext) => string;
/** i18n key prefix (e.g., "objectLabels", "zoneNames") */
i18nKey: string;
/** Translation namespace (default: "views/settings") */
namespace?: string;
/** Optional class name for the list container */
listClassName?: string;
/** Enable search input to filter the list */
enableSearch?: boolean;
};
function normalizeValue(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string");
}
if (typeof value === "string" && value.trim().length > 0) {
return [value.trim()];
}
return [];
}
/**
* Generic switches widget for selecting from any list of entities (objects, zones, etc.)
*
* @example
* // In uiSchema:
* "track": {
* "ui:widget": "switches",
* "ui:options": {
* "getEntities": (context) => [...],
* "i18nKey": "objectLabels"
* }
* }
*/
export function SwitchesWidget(props: WidgetProps) {
const { value, disabled, readonly, onChange, formContext, id, registry } =
props;
// Get configuration from widget options
const i18nKey = useMemo(
() => (props.options?.i18nKey as string | undefined) || "entities",
[props.options],
);
const namespace = useMemo(
() => (props.options?.namespace as string | undefined) || "views/settings",
[props.options],
);
// Try to get formContext from direct prop, options, or registry
const context = useMemo(
() =>
(formContext as FormContext | undefined) ||
(props.options?.formContext as FormContext | undefined) ||
(registry?.formContext as FormContext | undefined),
[formContext, props.options, registry],
);
const availableEntities = useMemo(() => {
const getEntities =
(props.options?.getEntities as
| ((context: FormContext) => string[])
| undefined) || (() => []);
if (context) {
return getEntities(context);
}
return [];
}, [context, props.options]);
const getDisplayLabel = useMemo(
() =>
(props.options?.getDisplayLabel as
| ((entity: string, context?: FormContext) => string)
| undefined) || ((entity: string) => entity),
[props.options],
);
const listClassName = useMemo(
() => props.options?.listClassName as string | undefined,
[props.options],
);
const enableSearch = useMemo(
() => props.options?.enableSearch as boolean | undefined,
[props.options],
);
const selectedEntities = useMemo(() => normalizeValue(value), [value]);
const [isOpen, setIsOpen] = useState(selectedEntities.length > 0);
const [searchTerm, setSearchTerm] = useState("");
const filteredEntities = useMemo(() => {
if (!enableSearch || !searchTerm.trim()) {
return availableEntities;
}
const term = searchTerm.toLowerCase();
return availableEntities.filter((entity) => {
const displayLabel = getDisplayLabel(entity, context);
return displayLabel.toLowerCase().includes(term);
});
}, [availableEntities, searchTerm, enableSearch, getDisplayLabel, context]);
const toggleEntity = (entity: string, enabled: boolean) => {
if (enabled) {
onChange([...selectedEntities, entity]);
} else {
onChange(selectedEntities.filter((item) => item !== entity));
}
};
const t = context?.t;
const summary = t
? t(`configForm.${i18nKey}.summary`, {
ns: namespace,
defaultValue: "{{count}} selected",
count: selectedEntities.length,
})
: `${selectedEntities.length} selected`;
const emptyMessage = t
? t(`configForm.${i18nKey}.empty`, {
ns: namespace,
defaultValue: "No items available",
})
: "No items available";
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="space-y-3">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="w-full justify-start gap-2 pl-0"
disabled={disabled || readonly}
>
{isOpen ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
{summary}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="rounded-lg bg-secondary p-2 pr-0 md:max-w-md">
{availableEntities.length === 0 ? (
<div className="text-sm text-muted-foreground">{emptyMessage}</div>
) : (
<>
{enableSearch && (
<div className="mr-2">
<Input
type="text"
placeholder={t?.("configForm.searchPlaceholder", {
ns: "views/settings",
defaultValue: "Search...",
})}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="mb-2"
/>
</div>
)}
<div className={cn("grid gap-2", listClassName)}>
{filteredEntities.map((entity) => {
const checked = selectedEntities.includes(entity);
const displayLabel = getDisplayLabel(entity, context);
return (
<div
key={entity}
className="flex items-center justify-between rounded-md px-3 py-0"
>
<label htmlFor={`${id}-${entity}`} className="text-sm">
{displayLabel}
</label>
<Switch
id={`${id}-${entity}`}
checked={checked}
disabled={disabled || readonly}
onCheckedChange={(value) =>
toggleEntity(entity, !!value)
}
/>
</div>
);
})}
</div>
</>
)}
</CollapsibleContent>
</div>
</Collapsible>
);
}
@@ -0,0 +1,74 @@
// Tags Widget - For array of strings input
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useState, useCallback, useMemo } from "react";
import { LuX } from "react-icons/lu";
export function TagsWidget(props: WidgetProps) {
const { id, value = [], disabled, readonly, onChange, schema } = props;
const [inputValue, setInputValue] = useState("");
const tags = useMemo(() => (Array.isArray(value) ? value : []), [value]);
const addTag = useCallback(() => {
const trimmed = inputValue.trim();
if (trimmed && !tags.includes(trimmed)) {
onChange([...tags, trimmed]);
setInputValue("");
}
}, [inputValue, tags, onChange]);
const removeTag = useCallback(
(tagToRemove: string) => {
onChange(tags.filter((tag: string) => tag !== tagToRemove));
},
[tags, onChange],
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
} else if (e.key === "Backspace" && inputValue === "" && tags.length > 0) {
removeTag(tags[tags.length - 1]);
}
};
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1">
{tags.map((tag: string, index: number) => (
<Badge key={`${tag}-${index}`} variant="secondary" className="gap-1">
{tag}
{!disabled && !readonly && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto p-0 hover:bg-transparent"
onClick={() => removeTag(tag)}
>
<LuX className="h-3 w-3" />
</Button>
)}
</Badge>
))}
</div>
{!readonly && (
<Input
id={id}
type="text"
value={inputValue}
disabled={disabled}
placeholder={`Add ${schema.title?.toLowerCase() || "item"}...`}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={addTag}
/>
)}
</div>
);
}
@@ -0,0 +1,48 @@
// Text Widget - maps to shadcn/ui Input
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { getSizedFieldClassName } from "../utils";
export function TextWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
const isNullable = Array.isArray(schema.type)
? schema.type.includes("null")
: false;
const fieldClassName = getSizedFieldClassName(options, "xs");
return (
<Input
id={id}
className={cn("text-md", fieldClassName)}
type="text"
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
onChange={(e) =>
onChange(
e.target.value === ""
? isNullable
? null
: undefined
: e.target.value,
)
}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
/>
);
}
@@ -0,0 +1,48 @@
// Textarea Widget - maps to shadcn/ui Textarea
import type { WidgetProps } from "@rjsf/utils";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { getSizedFieldClassName } from "../utils";
export function TextareaWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
const isNullable = Array.isArray(schema.type)
? schema.type.includes("null")
: false;
const fieldClassName = getSizedFieldClassName(options, "md");
return (
<Textarea
id={id}
className={cn("text-md", fieldClassName)}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
rows={(options.rows as number) || 3}
onChange={(e) =>
onChange(
e.target.value === ""
? isNullable
? null
: undefined
: e.target.value,
)
}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
/>
);
}
@@ -0,0 +1,64 @@
import { useMemo } from "react";
import type { WidgetProps } from "@rjsf/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTranslation } from "react-i18next";
import { getSizedFieldClassName } from "../utils";
const DEFAULT_TIMEZONE_VALUE = "__browser__";
function getTimezoneList(): string[] {
if (typeof Intl !== "undefined") {
const intl = Intl as typeof Intl & {
supportedValuesOf?: (key: string) => string[];
};
const supported = intl.supportedValuesOf?.("timeZone");
if (supported && supported.length > 0) {
return [...supported].sort();
}
}
const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone;
return fallback ? [fallback] : ["UTC"];
}
export function TimezoneSelectWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, schema, options } = props;
const { t } = useTranslation(["views/settings", "common"]);
const timezones = useMemo(() => getTimezoneList(), []);
const selectedValue = value ? String(value) : DEFAULT_TIMEZONE_VALUE;
const fieldClassName = getSizedFieldClassName(options, "sm");
const defaultLabel = t("configForm.timezone.defaultOption", {
ns: "views/settings",
});
return (
<Select
value={selectedValue}
onValueChange={(next) =>
onChange(next === DEFAULT_TIMEZONE_VALUE ? null : next)
}
disabled={disabled || readonly}
>
<SelectTrigger id={id} className={fieldClassName}>
<SelectValue placeholder={schema.title || defaultLabel} />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_TIMEZONE_VALUE}>{defaultLabel}</SelectItem>
{timezones.map((timezone) => (
<SelectItem key={timezone} value={timezone}>
{timezone}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default TimezoneSelectWidget;
@@ -0,0 +1,49 @@
// Zone Switches Widget - For selecting zones via switches
import type { WidgetProps } from "@rjsf/utils";
import { SwitchesWidget } from "./SwitchesWidget";
import type { FormContext } from "./SwitchesWidget";
function getZoneNames(context: FormContext): string[] {
if (context?.fullCameraConfig) {
const zones = context.fullCameraConfig.zones;
if (typeof zones === "object" && zones !== null) {
// zones is a dict/object, get the keys
return Object.keys(zones).sort();
}
}
return [];
}
function getZoneDisplayName(zoneName: string, context?: FormContext): string {
// Try to get the config from context
// In the config form context, we may not have the full config directly,
// so we'll try to use the zone config if available
if (context?.fullCameraConfig?.zones) {
const zones = context.fullCameraConfig.zones;
if (typeof zones === "object" && zones !== null) {
const zoneConfig = (zones as Record<string, { friendly_name?: string }>)[
zoneName
];
if (zoneConfig?.friendly_name) {
return zoneConfig.friendly_name;
}
}
}
// Fallback to cleaning up the zone name
return String(zoneName).replace(/_/g, " ");
}
export function ZoneSwitchesWidget(props: WidgetProps) {
return (
<SwitchesWidget
{...props}
options={{
...props.options,
getEntities: getZoneNames,
getDisplayLabel: getZoneDisplayName,
i18nKey: "zoneNames",
listClassName: "max-h-64 overflow-y-auto scrollbar-container",
}}
/>
);
}
@@ -0,0 +1,38 @@
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { LuRefreshCcw } from "react-icons/lu";
import { Tooltip, TooltipContent } from "../ui/tooltip";
import { TooltipTrigger } from "@radix-ui/react-tooltip";
type RestartRequiredIndicatorProps = {
className?: string;
iconClassName?: string;
};
export default function RestartRequiredIndicator({
className,
iconClassName,
}: RestartRequiredIndicatorProps) {
const { t } = useTranslation(["views/settings"]);
const restartRequiredLabel = t("configForm.restartRequiredField", {
ns: "views/settings",
defaultValue: "Restart required",
});
return (
<Tooltip>
<TooltipTrigger>
<span
className={cn(
"inline-flex cursor-default items-center text-muted-foreground",
className,
)}
aria-label={restartRequiredLabel}
>
<LuRefreshCcw className={cn("size-3", iconClassName)} />
</span>
</TooltipTrigger>
<TooltipContent>{restartRequiredLabel}</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,142 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuInfo, LuX } from "react-icons/lu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type SaveAllPreviewItem = {
scope: "global" | "camera";
cameraName?: string;
fieldPath: string;
value: unknown;
};
type SaveAllPreviewPopoverProps = {
items: SaveAllPreviewItem[];
className?: string;
align?: "start" | "center" | "end";
side?: "top" | "bottom" | "left" | "right";
};
export default function SaveAllPreviewPopover({
items,
className,
align = "end",
side = "bottom",
}: SaveAllPreviewPopoverProps) {
const { t } = useTranslation(["views/settings", "common"]);
const [open, setOpen] = useState(false);
const resetLabel = t("saveAllPreview.value.reset", {
ns: "views/settings",
});
const formatValue = useCallback(
(value: unknown) => {
if (value === "") return resetLabel;
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
},
[resetLabel],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn("size-8", className)}
aria-label={t("saveAllPreview.triggerLabel", {
ns: "views/settings",
})}
>
<LuInfo className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent
align={align}
side={side}
className="w-[90vw] max-w-sm border bg-background p-4 shadow-lg"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-primary-variant">
{t("saveAllPreview.title", { ns: "views/settings" })}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => setOpen(false)}
aria-label={t("button.close", { ns: "common" })}
>
<LuX className="size-4" />
</Button>
</div>
{items.length === 0 ? (
<div className="mt-3 text-xs text-muted-foreground">
{t("saveAllPreview.empty", { ns: "views/settings" })}
</div>
) : (
<div className="scrollbar-container mt-3 flex max-h-72 flex-col gap-2 overflow-y-auto pr-1">
{items.map((item) => {
const scopeLabel =
item.scope === "global"
? t("saveAllPreview.scope.global", {
ns: "views/settings",
})
: t("saveAllPreview.scope.camera", {
ns: "views/settings",
cameraName: item.cameraName,
});
return (
<div
key={`${item.scope}-${item.cameraName ?? "global"}-${
item.fieldPath
}`}
className="rounded-md border border-secondary bg-background_alt p-2"
>
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<span className="text-muted-foreground">
{t("saveAllPreview.scope.label", {
ns: "views/settings",
})}
</span>
<span className="truncate">{scopeLabel}</span>
<span className="text-muted-foreground">
{t("saveAllPreview.field.label", {
ns: "views/settings",
})}
</span>
<span className="break-all font-mono">
{item.fieldPath}
</span>
<span className="text-muted-foreground">
{t("saveAllPreview.value.label", {
ns: "views/settings",
})}
</span>
<span className="whitespace-pre-wrap break-words font-mono">
{formatValue(item.value)}
</span>
</div>
</div>
);
})}
</div>
)}
</PopoverContent>
</Popover>
);
}
+279
View File
@@ -0,0 +1,279 @@
// Hook to detect when camera config overrides global defaults
import { useMemo } from "react";
import isEqual from "lodash/isEqual";
import get from "lodash/get";
import set from "lodash/set";
import { FrigateConfig } from "@/types/frigateConfig";
import { JsonObject, JsonValue } from "@/types/configForm";
import { isJsonObject } from "@/lib/utils";
const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"];
function stripInternalFields(value: JsonValue): JsonValue {
if (Array.isArray(value)) {
return value.map(stripInternalFields);
}
if (isJsonObject(value)) {
const obj = value;
const cleaned: JsonObject = {};
for (const [key, val] of Object.entries(obj)) {
if (INTERNAL_FIELD_SUFFIXES.some((suffix) => key.endsWith(suffix))) {
continue;
}
cleaned[key] = stripInternalFields(val as JsonValue);
}
return cleaned;
}
return value;
}
export function normalizeConfigValue(value: unknown): JsonValue {
return stripInternalFields(value as JsonValue);
}
export interface OverrideStatus {
/** Whether the field is overridden from global */
isOverridden: boolean;
/** The global default value */
globalValue: unknown;
/** The camera-specific value */
cameraValue: unknown;
}
export interface UseConfigOverrideOptions {
/** Full Frigate config */
config: FrigateConfig | undefined;
/** Camera name for per-camera settings */
cameraName?: string;
/** Config section path (e.g., "detect", "record.events") */
sectionPath: string;
/** Optional list of field paths to compare for overrides */
compareFields?: string[];
}
function pickFields(value: unknown, fields: string[]): JsonObject {
if (!fields || fields.length === 0) {
return {};
}
const result: JsonObject = {};
fields.forEach((path) => {
if (!path) return;
const fieldValue = get(value as JsonObject, path);
if (fieldValue !== undefined) {
set(result, path, fieldValue);
}
});
return result;
}
/**
* Hook to detect config overrides between global and camera level
*
* @example
* ```tsx
* const { isOverridden, getFieldOverride } = useConfigOverride({
* config,
* cameraName: "front_door",
* sectionPath: "detect"
* });
*
* // Check if entire section is overridden
* if (isOverridden) {
* // Show override indicator
* }
*
* // Check specific field
* const fpsOverride = getFieldOverride("fps");
* ```
*/
export function useConfigOverride({
config,
cameraName,
sectionPath,
compareFields,
}: UseConfigOverrideOptions) {
return useMemo(() => {
if (!config) {
return {
isOverridden: false,
globalValue: undefined,
cameraValue: undefined,
getFieldOverride: () => ({
isOverridden: false,
globalValue: undefined,
cameraValue: undefined,
}),
resetToGlobal: () => undefined,
};
}
// Get global value for the section
const globalValue = get(config, sectionPath);
// If no camera specified, return global value info
if (!cameraName) {
return {
isOverridden: false,
globalValue,
cameraValue: globalValue,
getFieldOverride: (fieldPath: string): OverrideStatus => ({
isOverridden: false,
globalValue: get(globalValue, fieldPath),
cameraValue: get(globalValue, fieldPath),
}),
resetToGlobal: () => globalValue,
};
}
// Get camera-specific value
const cameraConfig = config.cameras?.[cameraName];
if (!cameraConfig) {
return {
isOverridden: false,
globalValue,
cameraValue: undefined,
getFieldOverride: () => ({
isOverridden: false,
globalValue: undefined,
cameraValue: undefined,
}),
resetToGlobal: () => globalValue,
};
}
const cameraValue = get(cameraConfig, sectionPath);
const normalizedGlobalValue = normalizeConfigValue(globalValue);
const normalizedCameraValue = normalizeConfigValue(cameraValue);
const comparisonGlobal = compareFields
? pickFields(normalizedGlobalValue, compareFields)
: normalizedGlobalValue;
const comparisonCamera = compareFields
? pickFields(normalizedCameraValue, compareFields)
: normalizedCameraValue;
// Check if the entire section is overridden
const isOverridden = compareFields
? compareFields.length > 0 && !isEqual(comparisonGlobal, comparisonCamera)
: !isEqual(comparisonGlobal, comparisonCamera);
/**
* Get override status for a specific field within the section
*/
const getFieldOverride = (fieldPath: string): OverrideStatus => {
const globalFieldValue = get(normalizedGlobalValue, fieldPath);
const cameraFieldValue = get(normalizedCameraValue, fieldPath);
return {
isOverridden: !isEqual(globalFieldValue, cameraFieldValue),
globalValue: globalFieldValue,
cameraValue: cameraFieldValue,
};
};
/**
* Returns the global value to reset camera override
*/
const resetToGlobal = (fieldPath?: string) => {
if (fieldPath) {
return get(normalizedGlobalValue, fieldPath);
}
return normalizedGlobalValue;
};
return {
isOverridden,
globalValue: normalizedGlobalValue,
cameraValue: normalizedCameraValue,
getFieldOverride,
resetToGlobal,
};
}, [config, cameraName, sectionPath, compareFields]);
}
/**
* Hook to get all overridden fields for a camera
*/
export function useAllCameraOverrides(
config: FrigateConfig | undefined,
cameraName: string | undefined,
) {
return useMemo(() => {
if (!config || !cameraName) {
return [];
}
const cameraConfig = config.cameras?.[cameraName];
if (!cameraConfig) {
return [];
}
const overriddenSections: string[] = [];
// Check each section that can be overridden
const sectionsToCheck: Array<{
key: string;
compareFields?: string[];
}> = [
{ key: "detect" },
{ key: "record" },
{ key: "snapshots" },
{ key: "motion" },
{ key: "objects" },
{ key: "review" },
{ key: "audio" },
{ key: "notifications" },
{ key: "live" },
{ key: "timestamp_style" },
{
key: "audio_transcription",
compareFields: ["enabled", "live_enabled"],
},
{ key: "birdseye", compareFields: ["enabled", "mode"] },
{ key: "face_recognition", compareFields: ["enabled", "min_area"] },
{
key: "ffmpeg",
compareFields: [
"path",
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
},
{
key: "lpr",
compareFields: ["enabled", "min_area", "enhancement"],
},
];
for (const { key, compareFields } of sectionsToCheck) {
const globalValue = normalizeConfigValue(get(config, key));
const cameraValue = normalizeConfigValue(get(cameraConfig, key));
const comparisonGlobal = compareFields
? pickFields(globalValue, compareFields)
: globalValue;
const comparisonCamera = compareFields
? pickFields(cameraValue, compareFields)
: cameraValue;
if (
compareFields && compareFields.length === 0
? false
: !isEqual(comparisonGlobal, comparisonCamera)
) {
overriddenSections.push(key);
}
}
return overriddenSections;
}, [config, cameraName]);
}
+132
View File
@@ -0,0 +1,132 @@
// Hook for efficiently working with config schemas
// Caches resolved section schemas to avoid repeated expensive resolution
import { useMemo } from "react";
import useSWR from "swr";
import { RJSFSchema } from "@rjsf/utils";
import { resolveAndCleanSchema } from "@/lib/config-schema";
// Cache for resolved section schemas - keyed by schema reference + section key
const sectionSchemaCache = new WeakMap<RJSFSchema, Map<string, RJSFSchema>>();
type SchemaWithDefinitions = RJSFSchema & {
$defs?: Record<string, RJSFSchema>;
definitions?: Record<string, RJSFSchema>;
properties?: Record<string, RJSFSchema>;
};
const getSchemaDefinitions = (schema: RJSFSchema): Record<string, RJSFSchema> =>
(schema as SchemaWithDefinitions).$defs ||
(schema as SchemaWithDefinitions).definitions ||
{};
/**
* Extracts and resolves a section schema from the full config schema
* Uses caching to avoid repeated expensive resolution
*/
function extractSectionSchema(
schema: RJSFSchema,
sectionPath: string,
level: "global" | "camera",
): RJSFSchema | null {
// Create cache key
const cacheKey = `${level}:${sectionPath}`;
// Check cache first (using WeakMap with schema as key for proper garbage collection)
let schemaCache = sectionSchemaCache.get(schema);
if (!schemaCache) {
schemaCache = new Map<string, RJSFSchema>();
sectionSchemaCache.set(schema, schemaCache);
}
if (schemaCache.has(cacheKey)) {
return schemaCache.get(cacheKey)!;
}
const defs = getSchemaDefinitions(schema);
const schemaObj = schema as SchemaWithDefinitions;
let sectionDef: RJSFSchema | null = null;
// For camera level, get section from CameraConfig in $defs
if (level === "camera") {
const cameraConfigDef = defs.CameraConfig;
if (cameraConfigDef?.properties) {
const props = cameraConfigDef.properties;
const sectionProp = props[sectionPath];
if (sectionProp && typeof sectionProp === "object") {
if ("$ref" in sectionProp && typeof sectionProp.$ref === "string") {
const refPath = sectionProp.$ref
.replace(/^#\/\$defs\//, "")
.replace(/^#\/definitions\//, "");
sectionDef = defs[refPath] || null;
} else {
sectionDef = sectionProp;
}
}
}
} else {
// For global level, get from root properties
if (schemaObj.properties) {
const props = schemaObj.properties;
const sectionProp = props[sectionPath];
if (sectionProp && typeof sectionProp === "object") {
if ("$ref" in sectionProp && typeof sectionProp.$ref === "string") {
const refPath = sectionProp.$ref
.replace(/^#\/\$defs\//, "")
.replace(/^#\/definitions\//, "");
sectionDef = defs[refPath] || null;
} else {
sectionDef = sectionProp;
}
}
}
}
if (!sectionDef) return null;
// Include $defs for nested references and resolve them
const schemaWithDefs: RJSFSchema = {
...sectionDef,
$defs: defs,
};
// Resolve all references and strip $defs from result
const resolved = resolveAndCleanSchema(schemaWithDefs);
// Cache the result
schemaCache.set(cacheKey, resolved);
return resolved;
}
/**
* Note: Cache is automatically cleared when schema changes since we use WeakMap
* with the schema object as key. No manual clearing needed.
*/
/**
* Hook to get a resolved section schema
* Efficiently caches resolved schemas to avoid repeated expensive operations
*/
export function useSectionSchema(
sectionPath: string,
level: "global" | "camera",
): RJSFSchema | null {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return useMemo(() => {
if (!schema) return null;
return extractSectionSchema(schema, sectionPath, level);
}, [schema, sectionPath, level]);
}
/**
* Hook to get the raw config schema
*/
export function useConfigSchema(): RJSFSchema | undefined {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return schema;
}
+115
View File
@@ -0,0 +1,115 @@
// Custom error messages for RJSF validation
// Maps JSON Schema validation keywords to user-friendly messages
import type { ErrorTransformer } from "@rjsf/utils";
import type { i18n as I18n } from "i18next";
/**
* Creates an error transformer function for RJSF
* Transforms technical JSON Schema errors into user-friendly messages
*/
export function createErrorTransformer(i18n: I18n): ErrorTransformer {
const t = i18n.t.bind(i18n);
const normalizeParams = (
params: Record<string, unknown> | undefined,
): Record<string, unknown> => {
if (!params) {
return {};
}
const allowedValues = params.allowedValues as unknown;
return {
...params,
allowedValues: Array.isArray(allowedValues)
? allowedValues.join(", ")
: allowedValues,
};
};
const getFieldPathFromProperty = (
property: string | undefined,
params: Record<string, unknown>,
errorType: string,
): string => {
const basePath = (property || "").replace(/^\./, "").trim();
const missingProperty = params.missingProperty as string | undefined;
if (errorType === "required" && missingProperty) {
return basePath ? `${basePath}.${missingProperty}` : missingProperty;
}
return basePath;
};
return (errors) =>
errors.map((error) => {
const errorType = error.name || "";
if (!errorType) {
return error;
}
const normalizedParams = normalizeParams(error.params);
const fieldPath = getFieldPathFromProperty(
error.property,
normalizedParams,
errorType,
);
let message: string | undefined;
// Try field-specific validation message first
if (fieldPath) {
const fieldKey = `${fieldPath}.validation.${errorType}`;
if (i18n.exists(fieldKey)) {
message = t(fieldKey, normalizedParams);
}
}
// Fall back to generic validation message
if (!message) {
const genericKey = errorType;
if (i18n.exists(genericKey, { ns: "config/validation" })) {
message = t(genericKey, {
...normalizedParams,
ns: ["config/validation"],
});
}
}
if (!message) {
return error;
}
return {
...error,
message,
};
});
}
/**
* Extracts field path from a Pydantic validation error location
*/
export function extractFieldPath(loc: (string | number)[]): string {
// Skip the first element if it's 'body' (FastAPI adds this)
const startIndex = loc[0] === "body" ? 1 : 0;
return loc.slice(startIndex).join(".");
}
/**
* Transforms Pydantic validation errors into RJSF-compatible errors
*/
export function transformPydanticErrors(
pydanticErrors: Array<{
loc: (string | number)[];
msg: string;
type: string;
}>,
): Array<{ property: string; message: string }> {
return pydanticErrors.map((error) => ({
property: extractFieldPath(error.loc),
message: error.msg,
}));
}
+17
View File
@@ -0,0 +1,17 @@
// Config Schema Utilities
// This module provides utilities for working with Frigate's JSON Schema
export {
transformSchema,
resolveSchemaRefs,
resolveAndCleanSchema,
extractSchemaSection,
applySchemaDefaults,
} from "./transformer";
export type { TransformedSchema, UiSchemaOptions } from "./transformer";
export {
createErrorTransformer,
transformPydanticErrors,
extractFieldPath,
} from "./errorMessages";
+682
View File
@@ -0,0 +1,682 @@
// Schema Transformer
// Converts Pydantic-generated JSON Schema to RJSF-compatible format with uiSchema
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
export interface TransformedSchema {
schema: RJSFSchema;
uiSchema: UiSchema;
}
export interface UiSchemaOptions {
/** Field ordering for the schema */
fieldOrder?: string[];
/** Fields to hide from the form */
hiddenFields?: string[];
/** Fields to mark as advanced (collapsed by default) */
advancedFields?: string[];
/** Custom widget mappings */
widgetMappings?: Record<string, string>;
/** Whether to include descriptions */
includeDescriptions?: boolean;
/** i18n namespace for field labels (e.g., "config/detect") */
i18nNamespace?: string;
}
// Type guard for schema objects
function isSchemaObject(
schema: unknown,
): schema is RJSFSchema & Record<string, unknown> {
return typeof schema === "object" && schema !== null;
}
function schemaHasType(schema: Record<string, unknown>, type: string): boolean {
const schemaType = schema.type;
if (Array.isArray(schemaType)) {
return schemaType.includes(type);
}
return schemaType === type;
}
/**
* Normalizes nullable schemas by unwrapping anyOf/oneOf [Type, null] patterns.
*
* When Pydantic generates JSON Schema for optional fields (e.g., Optional[int]),
* it creates anyOf/oneOf unions like: [{ type: "integer", ... }, { type: "null" }]
*
* This causes RJSF to treat the field as a multi-schema field with a dropdown selector,
* which leads to the field disappearing when the value is cleared (becomes undefined/null).
*
* This function unwraps these simple nullable patterns to a single non-null schema,
* allowing fields to remain visible and functional even when empty.
*
* @example
* // Input: { anyOf: [{ type: "integer" }, { type: "null" }] }
* // Output: { type: "integer" }
*
* @example
* // Input: { oneOf: [{ type: "string" }, { type: "null" }] }
* // Output: { type: "string" }
*/
function normalizeNullableSchema(schema: RJSFSchema): RJSFSchema {
if (!isSchemaObject(schema)) {
return schema;
}
const schemaObj = schema as Record<string, unknown>;
if (
schemaObj.default === null &&
schemaObj.type &&
!Array.isArray(schemaObj.type) &&
schemaObj.type !== "null"
) {
schemaObj.type = [schemaObj.type, "null"];
}
const anyOf = schemaObj.anyOf;
if (Array.isArray(anyOf)) {
const hasNull = anyOf.some(
(item) =>
isSchemaObject(item) &&
(item as Record<string, unknown>).type === "null",
);
const nonNull = anyOf.find(
(item) =>
isSchemaObject(item) &&
(item as Record<string, unknown>).type !== "null",
) as RJSFSchema | undefined;
if (hasNull && nonNull && anyOf.length === 2) {
const normalizedNonNull = normalizeNullableSchema(nonNull as RJSFSchema);
const normalizedNonNullObj = normalizedNonNull as Record<string, unknown>;
const nonNullType = normalizedNonNullObj.type;
const mergedType = Array.isArray(nonNullType)
? Array.from(new Set([...nonNullType, "null"]))
: nonNullType
? [nonNullType, "null"]
: ["null"];
const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj;
const merged: Record<string, unknown> = {
...rest,
...normalizedNonNullObj,
type: mergedType,
};
// When unwrapping a nullable enum, add null to the enum list so
// JSON Schema validation accepts the null default value.
if (Array.isArray(merged.enum)) {
merged.enum = [...(merged.enum as unknown[]), null];
}
return merged as RJSFSchema;
}
return {
...schemaObj,
anyOf: anyOf
.filter(isSchemaObject)
.map((item) => normalizeNullableSchema(item as RJSFSchema)),
} as RJSFSchema;
}
const oneOf = schemaObj.oneOf;
if (Array.isArray(oneOf)) {
const hasNull = oneOf.some(
(item) =>
isSchemaObject(item) &&
(item as Record<string, unknown>).type === "null",
);
const nonNull = oneOf.find(
(item) =>
isSchemaObject(item) &&
(item as Record<string, unknown>).type !== "null",
) as RJSFSchema | undefined;
if (hasNull && nonNull && oneOf.length === 2) {
const normalizedNonNull = normalizeNullableSchema(nonNull as RJSFSchema);
const normalizedNonNullObj = normalizedNonNull as Record<string, unknown>;
const nonNullType = normalizedNonNullObj.type;
const mergedType = Array.isArray(nonNullType)
? Array.from(new Set([...nonNullType, "null"]))
: nonNullType
? [nonNullType, "null"]
: ["null"];
const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj;
const merged: Record<string, unknown> = {
...rest,
...normalizedNonNullObj,
type: mergedType,
};
// When unwrapping a nullable oneOf enum, add null to the enum list.
if (Array.isArray(merged.enum)) {
merged.enum = [...(merged.enum as unknown[]), null];
}
return merged as RJSFSchema;
}
return {
...schemaObj,
oneOf: oneOf
.filter(isSchemaObject)
.map((item) => normalizeNullableSchema(item as RJSFSchema)),
} as RJSFSchema;
}
if (isSchemaObject(schemaObj.properties)) {
const normalizedProps: Record<string, RJSFSchema> = {};
for (const [key, prop] of Object.entries(
schemaObj.properties as Record<string, unknown>,
)) {
if (isSchemaObject(prop)) {
normalizedProps[key] = normalizeNullableSchema(prop as RJSFSchema);
}
}
return { ...schemaObj, properties: normalizedProps } as RJSFSchema;
}
if (schemaObj.items) {
if (Array.isArray(schemaObj.items)) {
return {
...schemaObj,
items: schemaObj.items
.filter(isSchemaObject)
.map((item) => normalizeNullableSchema(item as RJSFSchema)),
} as RJSFSchema;
} else if (isSchemaObject(schemaObj.items)) {
return {
...schemaObj,
items: normalizeNullableSchema(schemaObj.items as RJSFSchema),
} as RJSFSchema;
}
}
if (
schemaObj.additionalProperties &&
isSchemaObject(schemaObj.additionalProperties)
) {
return {
...schemaObj,
additionalProperties: normalizeNullableSchema(
schemaObj.additionalProperties as RJSFSchema,
),
} as RJSFSchema;
}
return schema;
}
/**
* Resolves $ref references in a JSON Schema
* This converts Pydantic's $defs-based schema to inline schemas
*/
export function resolveSchemaRefs(
schema: RJSFSchema,
rootSchema?: RJSFSchema,
): RJSFSchema {
const root = rootSchema || schema;
const defs =
(root as Record<string, unknown>).$defs ||
(root as Record<string, unknown>).definitions ||
{};
if (!schema || typeof schema !== "object") {
return schema;
}
const schemaObj = schema as Record<string, unknown>;
// Handle $ref
if (schemaObj.$ref && typeof schemaObj.$ref === "string") {
const refPath = schemaObj.$ref
.replace(/^#\/\$defs\//, "")
.replace(/^#\/definitions\//, "");
const resolved = (defs as Record<string, unknown>)[refPath];
if (isSchemaObject(resolved)) {
// Merge any additional properties from the original schema
const { $ref: _ref, ...rest } = schemaObj;
return resolveSchemaRefs({ ...resolved, ...rest } as RJSFSchema, root);
}
return schema;
}
// Handle allOf (Pydantic uses this for inheritance)
if (Array.isArray(schemaObj.allOf)) {
const merged: Record<string, unknown> = {};
for (const subSchema of schemaObj.allOf) {
if (isSchemaObject(subSchema)) {
const resolved = resolveSchemaRefs(subSchema as RJSFSchema, root);
Object.assign(merged, resolved);
if (
isSchemaObject(resolved) &&
(resolved as Record<string, unknown>).properties
) {
merged.properties = {
...(merged.properties as object),
...((resolved as Record<string, unknown>).properties as object),
};
}
if (
isSchemaObject(resolved) &&
Array.isArray((resolved as Record<string, unknown>).required)
) {
merged.required = [
...((merged.required as string[]) || []),
...((resolved as Record<string, unknown>).required as string[]),
];
}
}
}
// Include any extra properties from the parent schema
const { allOf: _allOf, ...rest } = schemaObj;
return { ...merged, ...rest } as RJSFSchema;
}
// Handle anyOf/oneOf
if (Array.isArray(schemaObj.anyOf)) {
return {
...schemaObj,
anyOf: schemaObj.anyOf
.filter(isSchemaObject)
.map((s) => resolveSchemaRefs(s as RJSFSchema, root)),
} as RJSFSchema;
}
if (Array.isArray(schemaObj.oneOf)) {
return {
...schemaObj,
oneOf: schemaObj.oneOf
.filter(isSchemaObject)
.map((s) => resolveSchemaRefs(s as RJSFSchema, root)),
} as RJSFSchema;
}
// Handle properties
if (isSchemaObject(schemaObj.properties)) {
const resolvedProps: Record<string, RJSFSchema> = {};
for (const [key, prop] of Object.entries(
schemaObj.properties as Record<string, unknown>,
)) {
if (isSchemaObject(prop)) {
resolvedProps[key] = resolveSchemaRefs(prop as RJSFSchema, root);
}
}
return { ...schemaObj, properties: resolvedProps } as RJSFSchema;
}
// Handle items (for arrays)
if (schemaObj.items) {
if (Array.isArray(schemaObj.items)) {
return {
...schemaObj,
items: schemaObj.items
.filter(isSchemaObject)
.map((item) => resolveSchemaRefs(item as RJSFSchema, root)),
} as RJSFSchema;
} else if (isSchemaObject(schemaObj.items)) {
return {
...schemaObj,
items: resolveSchemaRefs(schemaObj.items as RJSFSchema, root),
} as RJSFSchema;
}
}
// Handle additionalProperties (for dicts)
if (
schemaObj.additionalProperties &&
isSchemaObject(schemaObj.additionalProperties)
) {
return {
...schemaObj,
additionalProperties: resolveSchemaRefs(
schemaObj.additionalProperties as RJSFSchema,
root,
),
} as RJSFSchema;
}
return schema;
}
/**
* Wrapper that resolves refs and strips $defs from result
* Use this as the main entry point for resolving schemas
*/
export function resolveAndCleanSchema(schema: RJSFSchema): RJSFSchema {
const resolved = resolveSchemaRefs(schema);
// Remove $defs from result - they're no longer needed after resolution
const {
$defs: _defs,
definitions: _definitions,
...cleanSchema
} = resolved as Record<string, unknown>;
return cleanSchema as RJSFSchema;
}
/**
* Determines the appropriate widget for a schema field
*/
function getWidgetForField(
fieldName: string,
fieldSchema: RJSFSchema,
customMappings?: Record<string, string>,
): string | undefined {
// Check custom mappings first
if (customMappings?.[fieldName]) {
return customMappings[fieldName];
}
const schemaObj = fieldSchema as Record<string, unknown>;
// Password fields
if (
fieldName.toLowerCase().includes("password") ||
fieldName.toLowerCase().includes("secret")
) {
return "password";
}
// Color fields
if (
fieldName.toLowerCase().includes("color") &&
schemaHasType(schemaObj, "object")
) {
return "color";
}
// Enum fields get select widget
if (schemaObj.enum) {
return "select";
}
// Boolean fields get switch widget
if (schemaHasType(schemaObj, "boolean")) {
return "switch";
}
// Number with range gets slider
if (
(schemaHasType(schemaObj, "number") ||
schemaHasType(schemaObj, "integer")) &&
schemaObj.minimum !== undefined &&
schemaObj.maximum !== undefined
) {
return "range";
}
// Array of strings gets tags widget
if (
schemaHasType(schemaObj, "array") &&
isSchemaObject(schemaObj.items) &&
(schemaObj.items as Record<string, unknown>).type === "string"
) {
return "tags";
}
return undefined;
}
/**
* Generates a uiSchema for a given JSON Schema
*/
function generateUiSchema(
schema: RJSFSchema,
options: UiSchemaOptions = {},
currentPath: string[] = [],
): UiSchema {
const uiSchema: UiSchema = {};
const {
fieldOrder,
hiddenFields = [],
advancedFields = [],
widgetMappings = {},
includeDescriptions = true,
} = options;
// Pre-split patterns for wildcard matching ("*") on nested paths
const hiddenFieldPatterns = hiddenFields.map((field) => field.split("."));
const advancedFieldPatterns = advancedFields.map((field) => field.split("."));
// Match a concrete path to a wildcard pattern of equal length
const matchesPathPattern = (path: string[], pattern: string[]) => {
if (path.length !== pattern.length) {
return false;
}
return pattern.every((segment, index) => {
if (segment === "*") {
return true;
}
return segment === path[index];
});
};
const schemaObj = schema as Record<string, unknown>;
// Set field ordering
if (fieldOrder && fieldOrder.length > 0) {
uiSchema["ui:order"] = [...fieldOrder, "*"];
}
if (!isSchemaObject(schemaObj.properties)) {
return uiSchema;
}
for (const [fieldName, fieldSchema] of Object.entries(
schemaObj.properties as Record<string, unknown>,
)) {
if (!isSchemaObject(fieldSchema)) continue;
const fSchema = fieldSchema as Record<string, unknown>;
const fieldUiSchema: UiSchema = {};
// Track full path to support wildcard-based rules
const fieldPath = [...currentPath, fieldName];
// Hidden fields
if (
hiddenFieldPatterns.some((pattern) =>
matchesPathPattern(fieldPath, pattern),
)
) {
fieldUiSchema["ui:widget"] = "hidden";
uiSchema[fieldName] = fieldUiSchema;
continue;
}
// Widget selection
const widget = getWidgetForField(
fieldName,
fieldSchema as RJSFSchema,
widgetMappings,
);
if (widget) {
fieldUiSchema["ui:widget"] = widget;
}
// Description
if (!includeDescriptions && fSchema.description) {
fieldUiSchema["ui:description"] = "";
}
// Advanced fields - mark for collapsible
if (
advancedFieldPatterns.some((pattern) =>
matchesPathPattern(fieldPath, pattern),
)
) {
fieldUiSchema["ui:options"] = {
...((fieldUiSchema["ui:options"] as object) || {}),
advanced: true,
};
}
// Handle nested objects recursively
if (schemaHasType(fSchema, "object")) {
if (isSchemaObject(fSchema.properties)) {
Object.assign(
fieldUiSchema,
generateUiSchema(fieldSchema as RJSFSchema, options, fieldPath),
);
}
if (isSchemaObject(fSchema.additionalProperties)) {
// For dict-like schemas (additionalProperties), use "*" for path matching
const additionalSchema = generateUiSchema(
fSchema.additionalProperties as RJSFSchema,
options,
[...fieldPath, "*"],
);
if (Object.keys(additionalSchema).length > 0) {
fieldUiSchema.additionalProperties = additionalSchema;
}
}
}
if (Object.keys(fieldUiSchema).length > 0) {
uiSchema[fieldName] = fieldUiSchema;
}
}
return uiSchema;
}
/**
* Transforms a Pydantic JSON Schema to RJSF format
* Resolves references and generates appropriate uiSchema
*/
export function transformSchema(
rawSchema: RJSFSchema,
options: UiSchemaOptions = {},
): TransformedSchema {
// Resolve all $ref references and clean the result
const cleanSchema = resolveAndCleanSchema(rawSchema);
const normalizedSchema = normalizeNullableSchema(cleanSchema);
// Generate uiSchema
const uiSchema = generateUiSchema(normalizedSchema, options);
return {
schema: normalizedSchema,
uiSchema,
};
}
/**
* Extracts a subsection of the schema by path
* Useful for rendering individual config sections
*/
export function extractSchemaSection(
schema: RJSFSchema,
path: string,
): RJSFSchema | null {
const schemaObj = schema as Record<string, unknown>;
const defs = (schemaObj.$defs || schemaObj.definitions || {}) as Record<
string,
unknown
>;
const parts = path.split(".");
let current = schema as Record<string, unknown>;
for (const part of parts) {
if (!isSchemaObject(current.properties)) {
return null;
}
let propSchema = (current.properties as Record<string, unknown>)[
part
] as Record<string, unknown>;
if (!propSchema) {
return null;
}
// Resolve $ref if present
if (propSchema.$ref && typeof propSchema.$ref === "string") {
const refPath = (propSchema.$ref as string)
.replace(/^#\/\$defs\//, "")
.replace(/^#\/definitions\//, "");
const resolved = defs[refPath] as Record<string, unknown>;
if (resolved) {
// Merge any additional properties from original
const { $ref: _ref, ...rest } = propSchema;
propSchema = { ...resolved, ...rest };
} else {
return null;
}
}
current = propSchema;
}
// Return section schema with $defs included for nested ref resolution
const sectionWithDefs = {
...current,
$defs: defs,
} as RJSFSchema;
// Resolve all nested refs and clean the result
return resolveAndCleanSchema(sectionWithDefs);
}
/**
* Merges default values from schema into form data.
*
* Handles anyOf/oneOf schemas (e.g., `anyOf: [MotionConfig, null]`) by
* finding the non-null object branch and applying its property defaults.
*/
export function applySchemaDefaults(
schema: RJSFSchema,
formData: Record<string, unknown> = {},
): Record<string, unknown> {
const result = { ...formData };
const schemaObj = schema as Record<string, unknown>;
// Resolve properties, falling back to the non-null object branch of
// anyOf/oneOf schemas when top-level properties are not present.
let properties = schemaObj.properties;
if (!isSchemaObject(properties)) {
const branches = (schemaObj.anyOf ?? schemaObj.oneOf) as
| unknown[]
| undefined;
if (Array.isArray(branches)) {
const objectBranch = branches.find(
(s) =>
isSchemaObject(s) &&
(s as Record<string, unknown>).type !== "null" &&
isSchemaObject((s as Record<string, unknown>).properties),
) as Record<string, unknown> | undefined;
if (objectBranch) {
properties = objectBranch.properties;
}
}
}
if (!isSchemaObject(properties)) {
return result;
}
for (const [key, prop] of Object.entries(
properties as Record<string, unknown>,
)) {
if (!isSchemaObject(prop)) continue;
const propSchema = prop as Record<string, unknown>;
if (
result[key] === undefined &&
propSchema.default !== undefined &&
propSchema.default !== null
) {
result[key] = propSchema.default;
} else if (
schemaHasType(propSchema, "object") &&
isSchemaObject(propSchema.properties) &&
result[key] !== undefined
) {
result[key] = applySchemaDefaults(
prop as RJSFSchema,
result[key] as Record<string, unknown>,
);
}
}
return result;
}
+51
View File
@@ -1,6 +1,57 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { UiSchema } from "@rjsf/utils";
import { JsonObject } from "@/types/configForm";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Deep merges uiSchema objects, preserving nested properties from the base schema
* when overrides don't explicitly replace them.
*
* Special handling for ui:options - merges nested options rather than replacing them.
*/
export function mergeUiSchema(
base: UiSchema = {},
overrides: UiSchema = {},
): UiSchema {
const result: UiSchema = { ...base };
for (const [key, value] of Object.entries(overrides)) {
if (
key === "ui:options" &&
base[key] &&
typeof value === "object" &&
value !== null
) {
// Merge ui:options objects instead of replacing
result[key] = {
...(typeof base[key] === "object" && base[key] !== null
? base[key]
: {}),
...value,
};
} else if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
// Recursively merge nested objects (field configurations)
result[key] = mergeUiSchema(base[key] as UiSchema, value as UiSchema);
} else {
// Replace primitive values and arrays
result[key] = value;
}
}
return result;
}
/**
* Type guard to check if a value is a JsonObject (non-array object)
*/
export function isJsonObject(value: unknown): value is JsonObject {
return !!value && typeof value === "object" && !Array.isArray(value);
}
+1 -1
View File
@@ -533,7 +533,7 @@ function Logs() {
</div>
</div>
<div className="font-mono relative my-2 flex size-full flex-col overflow-hidden whitespace-pre-wrap rounded-md border border-secondary bg-background_alt text-xs sm:p-1">
<div className="relative my-2 flex size-full flex-col overflow-hidden whitespace-pre-wrap rounded-md border border-secondary bg-background_alt font-mono text-xs sm:p-1">
<div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
<div className="col-span-3 lg:col-span-2">
<div className="flex w-full flex-row items-center">
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More