mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-25 05:09:01 +03:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
+973
-70
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
Reference in New Issue
Block a user