use react-jsonschema-form for UI config

This commit is contained in:
Josh Hawkins
2026-02-27 09:37:56 -06:00
parent eeefbf2bb5
commit 68c74fef05
48 changed files with 4059 additions and 11 deletions
@@ -0,0 +1,157 @@
// ConfigForm - Main RJSF form wrapper component
import Form from "@rjsf/core";
import validator from "@rjsf/validator-ajv8";
import type { 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, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
export interface ConfigFormProps {
/** JSON Schema for the form */
schema: RJSFSchema;
/** Current form data */
formData?: Record<string, unknown>;
/** Called when form data changes */
onChange?: (data: Record<string, unknown>) => void;
/** Called when form is submitted */
onSubmit?: (data: Record<string, unknown>) => void;
/** Called when form has errors on submit */
onError?: (errors: unknown[]) => void;
/** Additional uiSchema overrides */
uiSchema?: UiSchema;
/** Field ordering */
fieldOrder?: 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?: Record<string, unknown>;
}
export function ConfigForm({
schema,
formData,
onChange,
onSubmit,
onError,
uiSchema: customUiSchema,
fieldOrder,
hiddenFields,
advancedFields,
disabled = false,
readonly = false,
showSubmit = true,
className,
liveValidate = false,
formContext,
}: ConfigFormProps) {
const { t } = useTranslation(["views/settings"]);
const [showAdvanced, setShowAdvanced] = useState(false);
// Determine which fields to hide based on advanced toggle
const effectiveHiddenFields = useMemo(() => {
if (showAdvanced || !advancedFields || advancedFields.length === 0) {
return hiddenFields;
}
// Hide advanced fields when toggle is off
return [...(hiddenFields || []), ...advancedFields];
}, [hiddenFields, advancedFields, showAdvanced]);
// Transform schema and generate uiSchema
const { schema: transformedSchema, uiSchema: generatedUiSchema } = useMemo(
() =>
transformSchema(schema, {
fieldOrder,
hiddenFields: effectiveHiddenFields,
advancedFields: showAdvanced ? advancedFields : [],
}),
[schema, fieldOrder, effectiveHiddenFields, advancedFields, showAdvanced],
);
// Merge generated uiSchema with custom overrides
const finalUiSchema = useMemo(
() => ({
...generatedUiSchema,
...customUiSchema,
"ui:submitButtonOptions": showSubmit
? { norender: false }
: { norender: true },
}),
[generatedUiSchema, customUiSchema, showSubmit],
);
// Create error transformer for user-friendly error messages
const errorTransformer = useMemo(() => createErrorTransformer(), []);
const handleChange = useCallback(
(e: IChangeEvent) => {
onChange?.(e.formData);
},
[onChange],
);
const handleSubmit = useCallback(
(e: IChangeEvent) => {
onSubmit?.(e.formData);
},
[onSubmit],
);
const hasAdvancedFields = advancedFields && advancedFields.length > 0;
return (
<div className={cn("config-form", className)}>
{hasAdvancedFields && (
<div className="mb-4 flex items-center justify-end gap-2">
<Switch
id="show-advanced"
checked={showAdvanced}
onCheckedChange={setShowAdvanced}
/>
<Label
htmlFor="show-advanced"
className="cursor-pointer text-sm text-muted-foreground"
>
{t("configForm.showAdvanced", {
defaultValue: "Show Advanced Settings",
})}
</Label>
</div>
)}
<Form
schema={transformedSchema}
uiSchema={finalUiSchema}
formData={formData}
validator={validator}
onChange={handleChange}
onSubmit={handleSubmit}
onError={onError}
disabled={disabled}
readonly={readonly}
liveValidate={liveValidate}
formContext={formContext}
transformErrors={errorTransformer}
{...frigateTheme}
/>
</div>
);
}
export default ConfigForm;
@@ -0,0 +1,30 @@
// Audio Section Component
// Reusable for both global and camera-level audio settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the audio section
export const audioSectionConfig: SectionConfig = {
fieldOrder: [
"enabled",
"listen",
"filters",
"min_volume",
"max_not_heard",
"num_threads",
],
fieldGroups: {
detection: ["listen", "filters"],
sensitivity: ["min_volume", "max_not_heard"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["min_volume", "max_not_heard", "num_threads"],
};
export const AudioSection = createConfigSection({
sectionPath: "audio",
translationKey: "configForm.audio",
defaultConfig: audioSectionConfig,
});
export default AudioSection;
@@ -0,0 +1,249 @@
// Base Section Component for config form sections
// Used as a foundation for reusable section components
import { useMemo, useCallback } from "react";
import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { ConfigForm } from "../ConfigForm";
import { useConfigOverride } from "@/hooks/use-config-override";
import { useSectionSchema } from "@/hooks/use-config-schema";
import type { FrigateConfig } from "@/types/frigateConfig";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { LuRotateCcw } from "react-icons/lu";
import get from "lodash/get";
export interface SectionConfig {
/** Field ordering within the section */
fieldOrder?: string[];
/** Fields to group together */
fieldGroups?: Record<string, string[]>;
/** Fields to hide from UI */
hiddenFields?: string[];
/** Fields to show in advanced section */
advancedFields?: string[];
}
export interface BaseSectionProps {
/** Whether this is at global or camera level */
level: "global" | "camera";
/** Camera name (required if level is "camera") */
cameraName?: string;
/** Whether to show override indicator badge */
showOverrideIndicator?: boolean;
/** Custom section configuration */
sectionConfig?: SectionConfig;
/** Whether the section is disabled */
disabled?: boolean;
/** Whether the section is read-only */
readonly?: boolean;
/** Callback when settings are saved */
onSave?: () => void;
/** Whether a restart is required after changes */
requiresRestart?: boolean;
}
export interface CreateSectionOptions {
/** The config path for this section (e.g., "detect", "record") */
sectionPath: string;
/** Translation key prefix for this section */
translationKey: string;
/** Default section configuration */
defaultConfig: SectionConfig;
}
/**
* Factory function to create reusable config section components
*/
export function createConfigSection({
sectionPath,
translationKey,
defaultConfig,
}: CreateSectionOptions) {
return function ConfigSection({
level,
cameraName,
showOverrideIndicator = true,
sectionConfig = defaultConfig,
disabled = false,
readonly = false,
onSave,
requiresRestart = true,
}: BaseSectionProps) {
const { t } = useTranslation(["views/settings"]);
// Fetch config
const { data: config, mutate: refreshConfig } =
useSWR<FrigateConfig>("config");
// Get section schema using cached hook
const sectionSchema = useSectionSchema(sectionPath, level);
// Get override status
const { isOverridden, globalValue, cameraValue, resetToGlobal } =
useConfigOverride({
config,
cameraName: level === "camera" ? cameraName : undefined,
sectionPath,
});
// Get current form data
const formData = useMemo(() => {
if (!config) return {};
if (level === "camera" && cameraName) {
return get(config.cameras?.[cameraName], sectionPath) || {};
}
return get(config, sectionPath) || {};
}, [config, level, cameraName]);
// Handle form submission
const handleSubmit = useCallback(
async (data: Record<string, unknown>) => {
try {
const basePath =
level === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}`
: sectionPath;
await axios.put("config/set", {
requires_restart: requiresRestart ? 1 : 0,
config_data: {
[basePath]: data,
},
});
toast.success(
t(`${translationKey}.toast.success`, {
defaultValue: "Settings saved successfully",
}),
);
refreshConfig();
onSave?.();
} catch (error) {
// Parse Pydantic validation errors from API response
if (axios.isAxiosError(error) && error.response?.data) {
const responseData = error.response.data;
// Pydantic errors come as { detail: [...] } or { message: "..." }
if (responseData.detail && Array.isArray(responseData.detail)) {
const validationMessages = responseData.detail
.map((err: { loc?: string[]; msg?: string }) => {
const field = err.loc?.slice(1).join(".") || "unknown";
return `${field}: ${err.msg || "Invalid value"}`;
})
.join(", ");
toast.error(
t(`${translationKey}.toast.validationError`, {
defaultValue: `Validation failed: ${validationMessages}`,
}),
);
} else if (responseData.message) {
toast.error(responseData.message);
} else {
toast.error(
t(`${translationKey}.toast.error`, {
defaultValue: "Failed to save settings",
}),
);
}
} else {
toast.error(
t(`${translationKey}.toast.error`, {
defaultValue: "Failed to save settings",
}),
);
}
}
},
[level, cameraName, requiresRestart, t, refreshConfig, onSave],
);
// Handle reset to global
const handleResetToGlobal = useCallback(async () => {
if (level !== "camera" || !cameraName) return;
try {
const basePath = `cameras.${cameraName}.${sectionPath}`;
// Reset by setting to null/undefined or removing the override
await axios.put("config/set", {
requires_restart: requiresRestart ? 1 : 0,
config_data: {
[basePath]: resetToGlobal(),
},
});
toast.success(
t(`${translationKey}.toast.resetSuccess`, {
defaultValue: "Reset to global defaults",
}),
);
refreshConfig();
} catch {
toast.error(
t(`${translationKey}.toast.resetError`, {
defaultValue: "Failed to reset settings",
}),
);
}
}, [level, cameraName, requiresRestart, t, refreshConfig, resetToGlobal]);
if (!sectionSchema) {
return null;
}
const title = t(`${translationKey}.title`, {
defaultValue: sectionPath.charAt(0).toUpperCase() + sectionPath.slice(1),
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-lg">{title}</CardTitle>
{showOverrideIndicator && level === "camera" && isOverridden && (
<Badge variant="secondary" className="text-xs">
{t("common.overridden", { defaultValue: "Overridden" })}
</Badge>
)}
</div>
{level === "camera" && isOverridden && (
<Button
variant="ghost"
size="sm"
onClick={handleResetToGlobal}
className="gap-2"
>
<LuRotateCcw className="h-4 w-4" />
{t("common.resetToGlobal", { defaultValue: "Reset to Global" })}
</Button>
)}
</CardHeader>
<CardContent>
<ConfigForm
schema={sectionSchema}
formData={formData}
onSubmit={handleSubmit}
fieldOrder={sectionConfig.fieldOrder}
hiddenFields={sectionConfig.hiddenFields}
advancedFields={sectionConfig.advancedFields}
disabled={disabled}
readonly={readonly}
formContext={{
level,
cameraName,
globalValue,
cameraValue,
}}
/>
</CardContent>
</Card>
);
};
}
@@ -0,0 +1,37 @@
// Detect Section Component
// Reusable for both global and camera-level detect settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the detect section
export const detectSectionConfig: SectionConfig = {
fieldOrder: [
"enabled",
"fps",
"width",
"height",
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
],
fieldGroups: {
resolution: ["width", "height"],
tracking: ["min_initialized", "max_disappeared"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: [
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
],
};
export const DetectSection = createConfigSection({
sectionPath: "detect",
translationKey: "configForm.detect",
defaultConfig: detectSectionConfig,
});
export default DetectSection;
@@ -0,0 +1,20 @@
// Live Section Component
// Reusable for both global and camera-level live settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the live section
export const liveSectionConfig: SectionConfig = {
fieldOrder: ["stream_name", "height", "quality"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: ["quality"],
};
export const LiveSection = createConfigSection({
sectionPath: "live",
translationKey: "configForm.live",
defaultConfig: liveSectionConfig,
});
export default LiveSection;
@@ -0,0 +1,42 @@
// Motion Section Component
// Reusable for both global and camera-level motion settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the motion section
export const motionSectionConfig: SectionConfig = {
fieldOrder: [
"enabled",
"threshold",
"lightning_threshold",
"improve_contrast",
"contour_area",
"delta_alpha",
"frame_alpha",
"frame_height",
"mask",
"mqtt_off_delay",
],
fieldGroups: {
sensitivity: ["threshold", "lightning_threshold", "contour_area"],
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: [
"lightning_threshold",
"improve_contrast",
"contour_area",
"delta_alpha",
"frame_alpha",
"frame_height",
"mqtt_off_delay",
],
};
export const MotionSection = createConfigSection({
sectionPath: "motion",
translationKey: "configForm.motion",
defaultConfig: motionSectionConfig,
});
export default MotionSection;
@@ -0,0 +1,20 @@
// Notifications Section Component
// Reusable for both global and camera-level notification settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the notifications section
export const notificationsSectionConfig: SectionConfig = {
fieldOrder: ["enabled", "email"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: [],
};
export const NotificationsSection = createConfigSection({
sectionPath: "notifications",
translationKey: "configForm.notifications",
defaultConfig: notificationsSectionConfig,
});
export default NotificationsSection;
@@ -0,0 +1,23 @@
// Objects Section Component
// Reusable for both global and camera-level objects settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the objects section
export const objectsSectionConfig: SectionConfig = {
fieldOrder: ["track", "alert", "detect", "filters", "mask"],
fieldGroups: {
tracking: ["track", "alert", "detect"],
filtering: ["filters", "mask"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["filters", "mask"],
};
export const ObjectsSection = createConfigSection({
sectionPath: "objects",
translationKey: "configForm.objects",
defaultConfig: objectsSectionConfig,
});
export default ObjectsSection;
@@ -0,0 +1,32 @@
// Record Section Component
// Reusable for both global and camera-level record settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the record section
export const recordSectionConfig: SectionConfig = {
fieldOrder: [
"enabled",
"expire_interval",
"continuous",
"motion",
"alerts",
"detections",
"preview",
"export",
],
fieldGroups: {
retention: ["continuous", "motion"],
events: ["alerts", "detections"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["expire_interval", "preview", "export"],
};
export const RecordSection = createConfigSection({
sectionPath: "record",
translationKey: "configForm.record",
defaultConfig: recordSectionConfig,
});
export default RecordSection;
@@ -0,0 +1,20 @@
// Review Section Component
// Reusable for both global and camera-level review settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the review section
export const reviewSectionConfig: SectionConfig = {
fieldOrder: ["alerts", "detections"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: [],
};
export const ReviewSection = createConfigSection({
sectionPath: "review",
translationKey: "configForm.review",
defaultConfig: reviewSectionConfig,
});
export default ReviewSection;
@@ -0,0 +1,29 @@
// Snapshots Section Component
// Reusable for both global and camera-level snapshots settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the snapshots section
export const snapshotsSectionConfig: SectionConfig = {
fieldOrder: [
"enabled",
"bounding_box",
"crop",
"quality",
"timestamp",
"retain",
],
fieldGroups: {
display: ["bounding_box", "crop", "quality", "timestamp"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["quality", "retain"],
};
export const SnapshotsSection = createConfigSection({
sectionPath: "snapshots",
translationKey: "configForm.snapshots",
defaultConfig: snapshotsSectionConfig,
});
export default SnapshotsSection;
@@ -0,0 +1,22 @@
// Timestamp Section Component
// Reusable for both global and camera-level timestamp_style settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the timestamp_style section
export const timestampSectionConfig: SectionConfig = {
fieldOrder: ["position", "format", "color", "thickness", "effect"],
fieldGroups: {
appearance: ["color", "thickness", "effect"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["thickness", "effect"],
};
export const TimestampSection = createConfigSection({
sectionPath: "timestamp_style",
translationKey: "configForm.timestampStyle",
defaultConfig: timestampSectionConfig,
});
export default TimestampSection;
@@ -0,0 +1,23 @@
// Config Form Section Components
// Reusable components for both global and camera-level settings
export {
createConfigSection,
type BaseSectionProps,
type SectionConfig,
type CreateSectionOptions,
} from "./BaseSection";
export { DetectSection, detectSectionConfig } from "./DetectSection";
export { RecordSection, recordSectionConfig } from "./RecordSection";
export { SnapshotsSection, snapshotsSectionConfig } from "./SnapshotsSection";
export { MotionSection, motionSectionConfig } from "./MotionSection";
export { ObjectsSection, objectsSectionConfig } from "./ObjectsSection";
export { ReviewSection, reviewSectionConfig } from "./ReviewSection";
export { AudioSection, audioSectionConfig } from "./AudioSection";
export {
NotificationsSection,
notificationsSectionConfig,
} from "./NotificationsSection";
export { LiveSection, liveSectionConfig } from "./LiveSection";
export { TimestampSection, timestampSectionConfig } from "./TimestampSection";
@@ -0,0 +1,67 @@
// Utilities for handling anyOf with null patterns
import type { StrictRJSFSchema } from "@rjsf/utils";
/**
* Checks if a schema is anyOf with exactly [PrimitiveType, null]
* where the primitive has no additional constraints
*/
export function isSimpleNullableField(schema: StrictRJSFSchema): boolean {
if (
!schema.anyOf ||
!Array.isArray(schema.anyOf) ||
schema.anyOf.length !== 2
) {
return false;
}
const items = schema.anyOf;
let hasNull = false;
let simpleType: StrictRJSFSchema | null = null;
// eslint-disable-next-line no-restricted-syntax
for (const item of items) {
if (typeof item !== "object" || item === null) {
return false;
}
const itemSchema = item as StrictRJSFSchema;
if (itemSchema.type === "null") {
hasNull = true;
} else if (
itemSchema.type &&
!("$ref" in itemSchema) &&
!("additionalProperties" in itemSchema) &&
!("items" in itemSchema) &&
!("pattern" in itemSchema) &&
!("minimum" in itemSchema) &&
!("maximum" in itemSchema) &&
!("exclusiveMinimum" in itemSchema) &&
!("exclusiveMaximum" in itemSchema)
) {
simpleType = itemSchema;
}
}
return hasNull && simpleType !== null;
}
/**
* Get the non-null schema from an anyOf containing [Type, null]
*/
export function getNonNullSchema(
schema: StrictRJSFSchema,
): StrictRJSFSchema | null {
if (!schema.anyOf || !Array.isArray(schema.anyOf)) {
return null;
}
return (
(schema.anyOf.find(
(item) =>
typeof item === "object" &&
item !== null &&
(item as StrictRJSFSchema).type !== "null",
) as StrictRJSFSchema) || null
);
}
@@ -0,0 +1,76 @@
// 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 { getDefaultRegistry } from "@rjsf/core";
import { SwitchWidget } from "./widgets/SwitchWidget";
import { SelectWidget } from "./widgets/SelectWidget";
import { TextWidget } from "./widgets/TextWidget";
import { PasswordWidget } from "./widgets/PasswordWidget";
import { CheckboxWidget } from "./widgets/CheckboxWidget";
import { RangeWidget } from "./widgets/RangeWidget";
import { TagsWidget } from "./widgets/TagsWidget";
import { ColorWidget } from "./widgets/ColorWidget";
import { TextareaWidget } from "./widgets/TextareaWidget";
import { FieldTemplate } from "./templates/FieldTemplate";
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
import { ArrayFieldTemplate } from "./templates/ArrayFieldTemplate";
import { BaseInputTemplate } from "./templates/BaseInputTemplate";
import { DescriptionFieldTemplate } from "./templates/DescriptionFieldTemplate";
import { TitleFieldTemplate } from "./templates/TitleFieldTemplate";
import { ErrorListTemplate } from "./templates/ErrorListTemplate";
import { SubmitButton } from "./templates/SubmitButton";
import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate";
export interface FrigateTheme {
widgets: RegistryWidgetsType;
templates: Partial<TemplatesType>;
fields: RegistryFieldsType;
}
const defaultRegistry = getDefaultRegistry();
export const frigateTheme: FrigateTheme = {
widgets: {
...defaultRegistry.widgets,
// Override default widgets with shadcn/ui styled versions
TextWidget: TextWidget,
PasswordWidget: PasswordWidget,
SelectWidget: SelectWidget,
CheckboxWidget: CheckboxWidget,
// Custom widgets
switch: SwitchWidget,
password: PasswordWidget,
select: SelectWidget,
range: RangeWidget,
tags: TagsWidget,
color: ColorWidget,
textarea: TextareaWidget,
},
templates: {
...defaultRegistry.templates,
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
ObjectFieldTemplate: ObjectFieldTemplate,
ArrayFieldTemplate: ArrayFieldTemplate,
BaseInputTemplate: BaseInputTemplate as React.ComponentType<WidgetProps>,
DescriptionFieldTemplate: DescriptionFieldTemplate,
TitleFieldTemplate: TitleFieldTemplate,
ErrorListTemplate: ErrorListTemplate,
MultiSchemaFieldTemplate: MultiSchemaFieldTemplate,
ButtonTemplates: {
...defaultRegistry.templates.ButtonTemplates,
SubmitButton: SubmitButton,
},
},
fields: {
...defaultRegistry.fields,
},
};
@@ -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,101 @@
// Array Field Template - renders array fields with add/remove controls
import type { ArrayFieldTemplateProps } from "@rjsf/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { LuPlus, LuTrash2, LuGripVertical } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
interface ArrayItem {
key: string;
index: number;
children: React.ReactNode;
hasRemove: boolean;
onDropIndexClick: (index: number) => () => void;
}
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 as unknown as ArrayItem[]).map((element: ArrayItem) => (
<div
key={element.key}
className={cn("flex items-start gap-2", !isSimpleType && "flex-col")}
>
{isSimpleType ? (
<div className="flex flex-1 items-center gap-2">
<LuGripVertical className="h-4 w-4 cursor-move text-muted-foreground" />
<div className="flex-1">{element.children}</div>
{element.hasRemove && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={element.onDropIndexClick(element.index)}
disabled={disabled || readonly}
className="h-8 w-8 text-destructive hover:text-destructive"
>
<LuTrash2 className="h-4 w-4" />
</Button>
)}
</div>
) : (
<Card className="w-full">
<CardContent className="pt-4">
<div className="flex items-start gap-2">
<LuGripVertical className="mt-2 h-4 w-4 cursor-move text-muted-foreground" />
<div className="flex-1">{element.children}</div>
{element.hasRemove && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={element.onDropIndexClick(element.index)}
disabled={disabled || readonly}
className="h-8 w-8 text-destructive hover:text-destructive"
>
<LuTrash2 className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
)}
</div>
))}
{canAdd && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onAddClick}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("add", { ns: "common", defaultValue: "Add" })}
</Button>
)}
</div>
);
}
@@ -0,0 +1,44 @@
// Base Input Template - default input wrapper
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
export function BaseInputTemplate(props: WidgetProps) {
const {
id,
type,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
} = props;
const inputType = type || "text";
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}
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,16 @@
// Description Field Template
import type { DescriptionFieldProps } from "@rjsf/utils";
export function DescriptionFieldTemplate(props: DescriptionFieldProps) {
const { description, id } = props;
if (!description) {
return null;
}
return (
<p id={id} className="text-sm text-muted-foreground">
{description}
</p>
);
}
@@ -0,0 +1,33 @@
// Error List Template - displays form-level errors
import type { ErrorListProps, RJSFValidationError } from "@rjsf/utils";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { LuCircleAlert } from "react-icons/lu";
import { useTranslation } from "react-i18next";
export function ErrorListTemplate(props: ErrorListProps) {
const { errors } = props;
const { t } = useTranslation(["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) => (
<li key={index} className="text-sm">
{error.property && (
<span className="font-medium">{error.property}: </span>
)}
{error.message}
</li>
))}
</ul>
</AlertDescription>
</Alert>
);
}
@@ -0,0 +1,81 @@
// Field Template - wraps each form field with label and description
import type { FieldTemplateProps } from "@rjsf/utils";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
export function FieldTemplate(props: FieldTemplateProps) {
const {
id,
label,
children,
errors,
help,
description,
hidden,
required,
displayLabel,
schema,
uiSchema,
} = props;
if (hidden) {
return <div className="hidden">{children}</div>;
}
// Get UI options
const uiOptions = uiSchema?.["ui:options"] || {};
const isAdvanced = uiOptions.advanced === true;
// Boolean fields (switches) render label inline
const isBoolean = schema.type === "boolean";
return (
<div
className={cn(
"space-y-2",
isAdvanced && "border-l-2 border-muted pl-4",
isBoolean && "flex items-center justify-between gap-4",
)}
>
{displayLabel && label && !isBoolean && (
<Label
htmlFor={id}
className={cn(
"text-sm font-medium",
errors && errors.props?.errors?.length > 0 && "text-destructive",
)}
>
{label}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
{isBoolean ? (
<div className="flex w-full items-center justify-between gap-4">
<div className="space-y-0.5">
{displayLabel && label && (
<Label htmlFor={id} className="text-sm font-medium">
{label}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
{children}
</div>
) : (
<>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
{children}
</>
)}
{errors}
{help}
</div>
);
}
@@ -0,0 +1,40 @@
// Custom MultiSchemaFieldTemplate to handle anyOf [Type, null] fields
// Renders simple nullable types as single inputs instead of dropdowns
import type {
MultiSchemaFieldTemplateProps,
StrictRJSFSchema,
FormContextType,
} from "@rjsf/utils";
import { isSimpleNullableField } 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 } = props;
// Check if this is a simple nullable field that should be handled specially
if (isSimpleNullableField(schema)) {
// 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,118 @@
// Object Field Template - renders nested object fields
import type { ObjectFieldTemplateProps } from "@rjsf/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const { title, description, properties } = props;
// Check if this is a root-level object
const isRoot = !title;
const [isOpen, setIsOpen] = useState(true);
// Check for advanced section grouping
const advancedProps = properties.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
);
const regularProps = properties.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
);
const [showAdvanced, setShowAdvanced] = useState(false);
// Root level renders children directly
if (isRoot) {
return (
<div className="space-y-6">
{regularProps.map((element) => (
<div key={element.name}>{element.content}</div>
))}
{advancedProps.length > 0 && (
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-2">
{showAdvanced ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
Advanced Settings ({advancedProps.length})
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{advancedProps.map((element) => (
<div key={element.name}>{element.content}</div>
))}
</CollapsibleContent>
</Collapsible>
)}
</div>
);
}
// Nested objects render as collapsible cards
return (
<Card>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer transition-colors hover:bg-muted/50">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{title}</CardTitle>
{description && (
<p className="mt-1 text-sm text-muted-foreground">
{description}
</p>
)}
</div>
{isOpen ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 pt-0">
{regularProps.map((element) => (
<div key={element.name}>{element.content}</div>
))}
{advancedProps.length > 0 && (
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2"
>
{showAdvanced ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
Advanced ({advancedProps.length})
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{advancedProps.map((element) => (
<div key={element.name}>{element.content}</div>
))}
</CollapsibleContent>
</Collapsible>
)}
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
@@ -0,0 +1,21 @@
// Submit Button Template
import type { SubmitButtonProps } from "@rjsf/utils";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { LuSave } from "react-icons/lu";
export function SubmitButton(props: SubmitButtonProps) {
const { uiSchema } = props;
const { t } = useTranslation(["common"]);
const submitText =
(uiSchema?.["ui:options"]?.submitText as string) ||
t("save", { ns: "common" });
return (
<Button type="submit" className="gap-2">
<LuSave className="h-4 w-4" />
{submitText}
</Button>
);
}
@@ -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,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,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,55 @@
// 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";
export function PasswordWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
} = props;
const [showPassword, setShowPassword] = useState(false);
return (
<div className="relative">
<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="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,49 @@
// Select Widget - maps to shadcn/ui Select
import type { WidgetProps } from "@rjsf/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function SelectWidget(props: WidgetProps) {
const {
id,
options,
value,
disabled,
readonly,
onChange,
placeholder,
schema,
} = props;
const { enumOptions = [] } = options;
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="w-full">
<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,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,34 @@
// Text Widget - maps to shadcn/ui Input
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
export function TextWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
return (
<Input
id={id}
type="text"
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
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}
/>
);
}
@@ -0,0 +1,34 @@
// Textarea Widget - maps to shadcn/ui Textarea
import type { WidgetProps } from "@rjsf/utils";
import { Textarea } from "@/components/ui/textarea";
export function TextareaWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
return (
<Textarea
id={id}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
rows={(options.rows as number) || 3}
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}
/>
);
}
+182
View File
@@ -0,0 +1,182 @@
// Hook to detect when camera config overrides global defaults
import { useMemo } from "react";
import isEqual from "lodash/isEqual";
import get from "lodash/get";
import type { FrigateConfig } from "@/types/frigateConfig";
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;
}
/**
* 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,
}: 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);
// Check if the entire section is overridden
const isOverridden = !isEqual(globalValue, cameraValue);
/**
* Get override status for a specific field within the section
*/
const getFieldOverride = (fieldPath: string): OverrideStatus => {
const globalFieldValue = get(globalValue, fieldPath);
const cameraFieldValue = get(cameraValue, 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(globalValue, fieldPath);
}
return globalValue;
};
return {
isOverridden,
globalValue,
cameraValue,
getFieldOverride,
resetToGlobal,
};
}, [config, cameraName, sectionPath]);
}
/**
* 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 = [
"detect",
"record",
"snapshots",
"motion",
"objects",
"review",
"audio",
"notifications",
"live",
"timestamp_style",
];
for (const section of sectionsToCheck) {
const globalValue = get(config, section);
const cameraValue = get(cameraConfig, section);
if (!isEqual(globalValue, cameraValue)) {
overriddenSections.push(section);
}
}
return overriddenSections;
}, [config, cameraName]);
}
+128
View File
@@ -0,0 +1,128 @@
// 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 type { 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>>();
/**
* 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 schemaObj = schema as Record<string, unknown>;
const defs = (schemaObj.$defs || schemaObj.definitions || {}) as Record<
string,
unknown
>;
let sectionDef: Record<string, unknown> | null = null;
// For camera level, get section from CameraConfig in $defs
if (level === "camera") {
const cameraConfigDef = defs.CameraConfig as
| Record<string, unknown>
| undefined;
if (cameraConfigDef?.properties) {
const props = cameraConfigDef.properties as Record<string, unknown>;
const sectionProp = props[sectionPath];
if (sectionProp && typeof sectionProp === "object") {
const refProp = sectionProp as Record<string, unknown>;
if (refProp.$ref && typeof refProp.$ref === "string") {
const refPath = (refProp.$ref as string)
.replace(/^#\/\$defs\//, "")
.replace(/^#\/definitions\//, "");
sectionDef = defs[refPath] as Record<string, unknown>;
} else {
sectionDef = sectionProp as Record<string, unknown>;
}
}
}
} else {
// For global level, get from root properties
if (schemaObj.properties) {
const props = schemaObj.properties as Record<string, unknown>;
const sectionProp = props[sectionPath];
if (sectionProp && typeof sectionProp === "object") {
const refProp = sectionProp as Record<string, unknown>;
if (refProp.$ref && typeof refProp.$ref === "string") {
const refPath = (refProp.$ref as string)
.replace(/^#\/\$defs\//, "")
.replace(/^#\/definitions\//, "");
sectionDef = defs[refPath] as Record<string, unknown>;
} else {
sectionDef = sectionProp as Record<string, unknown>;
}
}
}
}
if (!sectionDef) return null;
// Include $defs for nested references and resolve them
const schemaWithDefs = {
...sectionDef,
$defs: defs,
} as RJSFSchema;
// 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,99 @@
// Custom error messages for RJSF validation
// Maps JSON Schema validation keywords to user-friendly messages
import type { ErrorTransformer } from "@rjsf/utils";
export interface ErrorMessageMap {
[keyword: string]: string | ((params: Record<string, unknown>) => string);
}
// Default error messages for common validation keywords
export const defaultErrorMessages: ErrorMessageMap = {
required: "This field is required",
type: (params) => {
const expectedType = params.type as string;
return `Expected ${expectedType} value`;
},
minimum: (params) => `Must be at least ${params.limit}`,
maximum: (params) => `Must be at most ${params.limit}`,
minLength: (params) => `Must be at least ${params.limit} characters`,
maxLength: (params) => `Must be at most ${params.limit} characters`,
pattern: "Invalid format",
format: (params) => {
const format = params.format as string;
const formatLabels: Record<string, string> = {
email: "Invalid email address",
uri: "Invalid URL",
"date-time": "Invalid date/time format",
ipv4: "Invalid IP address",
ipv6: "Invalid IPv6 address",
};
return formatLabels[format] || `Invalid ${format} format`;
},
enum: "Must be one of the allowed values",
const: "Value does not match expected constant",
uniqueItems: "All items must be unique",
minItems: (params) => `Must have at least ${params.limit} items`,
maxItems: (params) => `Must have at most ${params.limit} items`,
additionalProperties: "Unknown property is not allowed",
oneOf: "Must match exactly one of the allowed schemas",
anyOf: "Must match at least one of the allowed schemas",
};
/**
* Creates an error transformer function for RJSF
* Transforms technical JSON Schema errors into user-friendly messages
*/
export function createErrorTransformer(
customMessages: ErrorMessageMap = {},
): ErrorTransformer {
const messages = { ...defaultErrorMessages, ...customMessages };
return (errors) => {
return errors.map((error) => {
const keyword = error.name || "";
const messageTemplate = messages[keyword];
if (!messageTemplate) {
return error;
}
let message: string;
if (typeof messageTemplate === "function") {
message = messageTemplate(error.params || {});
} else {
message = messageTemplate;
}
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,
}));
}
+19
View File
@@ -0,0 +1,19 @@
// 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,
defaultErrorMessages,
} from "./errorMessages";
export type { ErrorMessageMap } from "./errorMessages";
+447
View File
@@ -0,0 +1,447 @@
// 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;
}
// Type guard for schema objects
function isSchemaObject(
schema: unknown,
): schema is RJSFSchema & Record<string, unknown> {
return typeof schema === "object" && schema !== null;
}
/**
* 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") &&
schemaObj.type === "object"
) {
return "color";
}
// Enum fields get select widget
if (schemaObj.enum) {
return "select";
}
// Boolean fields get switch widget
if (schemaObj.type === "boolean") {
return "switch";
}
// Number with range gets slider
if (
(schemaObj.type === "number" || schemaObj.type === "integer") &&
schemaObj.minimum !== undefined &&
schemaObj.maximum !== undefined
) {
return "range";
}
// Array of strings gets tags widget
if (
schemaObj.type === "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 = {},
): UiSchema {
const uiSchema: UiSchema = {};
const {
fieldOrder,
hiddenFields = [],
advancedFields = [],
widgetMappings = {},
includeDescriptions = true,
} = options;
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 = {};
// Hidden fields
if (hiddenFields.includes(fieldName)) {
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 (advancedFields.includes(fieldName)) {
fieldUiSchema["ui:options"] = {
...((fieldUiSchema["ui:options"] as object) || {}),
advanced: true,
};
}
// Handle nested objects recursively
if (fSchema.type === "object" && isSchemaObject(fSchema.properties)) {
const nestedOptions: UiSchemaOptions = {
hiddenFields: hiddenFields
.filter((f) => f.startsWith(`${fieldName}.`))
.map((f) => f.replace(`${fieldName}.`, "")),
advancedFields: advancedFields
.filter((f) => f.startsWith(`${fieldName}.`))
.map((f) => f.replace(`${fieldName}.`, "")),
widgetMappings: Object.fromEntries(
Object.entries(widgetMappings)
.filter(([k]) => k.startsWith(`${fieldName}.`))
.map(([k, v]) => [k.replace(`${fieldName}.`, ""), v]),
),
includeDescriptions,
};
Object.assign(
fieldUiSchema,
generateUiSchema(fieldSchema as RJSFSchema, nestedOptions),
);
}
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);
// Generate uiSchema
const uiSchema = generateUiSchema(cleanSchema, options);
return {
schema: cleanSchema,
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
*/
export function applySchemaDefaults(
schema: RJSFSchema,
formData: Record<string, unknown> = {},
): Record<string, unknown> {
const result = { ...formData };
const schemaObj = schema as Record<string, unknown>;
if (!isSchemaObject(schemaObj.properties)) {
return result;
}
for (const [key, prop] of Object.entries(
schemaObj.properties as Record<string, unknown>,
)) {
if (!isSchemaObject(prop)) continue;
const propSchema = prop as Record<string, unknown>;
if (result[key] === undefined && propSchema.default !== undefined) {
result[key] = propSchema.default;
} else if (
propSchema.type === "object" &&
isSchemaObject(propSchema.properties) &&
result[key] !== undefined
) {
result[key] = applySchemaDefaults(
prop as RJSFSchema,
result[key] as Record<string, unknown>,
);
}
}
return result;
}
+10 -1
View File
@@ -37,6 +37,8 @@ import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView";
import UiSettingsView from "@/views/settings/UiSettingsView";
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
import MaintenanceSettingsView from "@/views/settings/MaintenanceSettingsView";
import GlobalConfigView from "@/views/settings/GlobalConfigView";
import CameraConfigView from "@/views/settings/CameraConfigView";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useInitialCameraState } from "@/api/ws";
@@ -71,6 +73,8 @@ import {
const allSettingsViews = [
"ui",
"globalConfig",
"cameraConfig",
"enrichments",
"cameraManagement",
"cameraReview",
@@ -89,11 +93,15 @@ type SettingsType = (typeof allSettingsViews)[number];
const settingsGroups = [
{
label: "general",
items: [{ key: "ui", component: UiSettingsView }],
items: [
{ key: "ui", component: UiSettingsView },
{ key: "globalConfig", component: GlobalConfigView },
],
},
{
label: "cameras",
items: [
{ key: "cameraConfig", component: CameraConfigView },
{ key: "cameraManagement", component: CameraManagementView },
{ key: "cameraReview", component: CameraReviewSettingsView },
{ key: "masksAndZones", component: MasksAndZonesView },
@@ -130,6 +138,7 @@ const settingsGroups = [
const CAMERA_SELECT_BUTTON_PAGES = [
"debug",
"cameraConfig",
"cameraReview",
"masksAndZones",
"motionTuner",
+265
View File
@@ -0,0 +1,265 @@
// Camera Configuration View
// Per-camera configuration with tab navigation and override indicators
import { useMemo, useCallback, useState } from "react";
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import {
DetectSection,
RecordSection,
SnapshotsSection,
MotionSection,
ObjectsSection,
ReviewSection,
AudioSection,
NotificationsSection,
LiveSection,
TimestampSection,
} from "@/components/config-form/sections";
import { useAllCameraOverrides } from "@/hooks/use-config-override";
import type { FrigateConfig } from "@/types/frigateConfig";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { cn } from "@/lib/utils";
interface CameraConfigViewProps {
/** Currently selected camera (from parent) */
selectedCamera?: string;
/** Callback when unsaved changes state changes */
setUnsavedChanges?: React.Dispatch<React.SetStateAction<boolean>>;
}
export default function CameraConfigView({
selectedCamera: externalSelectedCamera,
setUnsavedChanges,
}: CameraConfigViewProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: refreshConfig } =
useSWR<FrigateConfig>("config");
// Get list of cameras
const cameras = useMemo(() => {
if (!config?.cameras) return [];
return Object.keys(config.cameras).sort();
}, [config]);
// Selected camera state (use external if provided, else internal)
const [internalSelectedCamera, setInternalSelectedCamera] = useState<string>(
cameras[0] || "",
);
const selectedCamera = externalSelectedCamera || internalSelectedCamera;
// Get overridden sections for current camera
const overriddenSections = useAllCameraOverrides(config, selectedCamera);
const handleSave = useCallback(() => {
refreshConfig();
setUnsavedChanges?.(false);
}, [refreshConfig, setUnsavedChanges]);
const handleCameraChange = useCallback((camera: string) => {
setInternalSelectedCamera(camera);
}, []);
if (!config) {
return (
<div className="flex h-full items-center justify-center">
<ActivityIndicator />
</div>
);
}
if (cameras.length === 0) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
{t("configForm.camera.noCameras", {
defaultValue: "No cameras configured",
})}
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">
{t("configForm.camera.title", {
defaultValue: "Camera Configuration",
})}
</h2>
<p className="text-muted-foreground">
{t("configForm.camera.description", {
defaultValue:
"Configure settings for individual cameras. Overridden settings are highlighted.",
})}
</p>
</div>
{/* Camera Tabs - Only show if not externally controlled */}
{!externalSelectedCamera && (
<Tabs
value={selectedCamera}
onValueChange={handleCameraChange}
className="w-full"
>
<ScrollArea className="w-full">
<TabsList className="inline-flex w-max">
{cameras.map((camera) => {
const cameraOverrides = overriddenSections.filter((s) =>
s.startsWith(camera),
);
const hasOverrides = cameraOverrides.length > 0;
const cameraConfig = config.cameras[camera];
const displayName = cameraConfig?.name || camera;
return (
<TabsTrigger
key={camera}
value={camera}
className="relative gap-2"
>
{displayName}
{hasOverrides && (
<Badge variant="secondary" className="ml-1 h-5 px-1.5">
{cameraOverrides.length}
</Badge>
)}
</TabsTrigger>
);
})}
</TabsList>
</ScrollArea>
{cameras.map((camera) => (
<TabsContent key={camera} value={camera} className="mt-4">
<CameraConfigContent
cameraName={camera}
config={config}
overriddenSections={overriddenSections}
onSave={handleSave}
/>
</TabsContent>
))}
</Tabs>
)}
{/* Direct content when externally controlled */}
{externalSelectedCamera && (
<CameraConfigContent
cameraName={externalSelectedCamera}
config={config}
overriddenSections={overriddenSections}
onSave={handleSave}
/>
)}
</div>
);
}
interface CameraConfigContentProps {
cameraName: string;
config: FrigateConfig;
overriddenSections: string[];
onSave: () => void;
}
function CameraConfigContent({
cameraName,
config,
overriddenSections,
onSave,
}: CameraConfigContentProps) {
const { t } = useTranslation(["views/settings"]);
const [activeSection, setActiveSection] = useState("detect");
const cameraConfig = config.cameras?.[cameraName];
if (!cameraConfig) {
return (
<div className="text-muted-foreground">
{t("configForm.camera.notFound", { defaultValue: "Camera not found" })}
</div>
);
}
const sections = [
{ key: "detect", label: "Detect", component: DetectSection },
{ key: "record", label: "Record", component: RecordSection },
{ key: "snapshots", label: "Snapshots", component: SnapshotsSection },
{ key: "motion", label: "Motion", component: MotionSection },
{ key: "objects", label: "Objects", component: ObjectsSection },
{ key: "review", label: "Review", component: ReviewSection },
{ key: "audio", label: "Audio", component: AudioSection },
{
key: "notifications",
label: "Notifications",
component: NotificationsSection,
},
{ key: "live", label: "Live", component: LiveSection },
{ key: "timestamp_style", label: "Timestamp", component: TimestampSection },
];
return (
<div className="flex gap-6">
{/* Section Navigation */}
<nav className="w-48 shrink-0">
<ul className="space-y-1">
{sections.map((section) => {
const isOverridden = overriddenSections.includes(section.key);
return (
<li key={section.key}>
<button
onClick={() => setActiveSection(section.key)}
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors",
activeSection === section.key
? "bg-accent text-accent-foreground"
: "hover:bg-muted",
)}
>
<span>
{t(`configForm.${section.key}.title`, {
defaultValue: section.label,
})}
</span>
{isOverridden && (
<Badge variant="secondary" className="h-5 px-1.5 text-xs">
{t("common.modified", { defaultValue: "Modified" })}
</Badge>
)}
</button>
</li>
);
})}
</ul>
</nav>
{/* Section Content */}
<ScrollArea className="h-[calc(100vh-300px)] flex-1">
<div className="pr-4">
{sections.map((section) => {
const SectionComponent = section.component;
return (
<div
key={section.key}
className={cn(
activeSection === section.key ? "block" : "hidden",
)}
>
<SectionComponent
level="camera"
cameraName={cameraName}
showOverrideIndicator
onSave={onSave}
/>
</div>
);
})}
</div>
</ScrollArea>
</div>
);
}
+328
View File
@@ -0,0 +1,328 @@
// Global Configuration View
// Main view for configuring global Frigate settings
import { useMemo, useCallback, useState } from "react";
import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { ConfigForm } from "@/components/config-form/ConfigForm";
import {
DetectSection,
RecordSection,
SnapshotsSection,
MotionSection,
ObjectsSection,
ReviewSection,
AudioSection,
NotificationsSection,
LiveSection,
TimestampSection,
} from "@/components/config-form/sections";
import type { RJSFSchema } from "@rjsf/utils";
import type { FrigateConfig } from "@/types/frigateConfig";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { extractSchemaSection } from "@/lib/config-schema";
import ActivityIndicator from "@/components/indicators/activity-indicator";
// Section configurations for global-only settings
const globalSectionConfigs: Record<
string,
{ fieldOrder?: string[]; hiddenFields?: string[]; advancedFields?: string[] }
> = {
mqtt: {
fieldOrder: [
"enabled",
"host",
"port",
"user",
"password",
"topic_prefix",
"client_id",
"stats_interval",
"tls_ca_certs",
"tls_client_cert",
"tls_client_key",
"tls_insecure",
],
advancedFields: [
"stats_interval",
"tls_ca_certs",
"tls_client_cert",
"tls_client_key",
"tls_insecure",
],
},
database: {
fieldOrder: ["path"],
advancedFields: [],
},
auth: {
fieldOrder: [
"enabled",
"reset_admin_password",
"native_oauth_url",
"failed_login_rate_limit",
"trusted_proxies",
],
advancedFields: ["failed_login_rate_limit", "trusted_proxies"],
},
tls: {
fieldOrder: ["enabled", "cert", "key"],
advancedFields: [],
},
telemetry: {
fieldOrder: ["network_interfaces", "stats", "version_check"],
advancedFields: ["stats"],
},
birdseye: {
fieldOrder: [
"enabled",
"restream",
"width",
"height",
"quality",
"mode",
"layout",
"inactivity_threshold",
],
advancedFields: ["width", "height", "quality", "inactivity_threshold"],
},
semantic_search: {
fieldOrder: ["enabled", "reindex", "model_size"],
advancedFields: ["reindex"],
},
face_recognition: {
fieldOrder: ["enabled", "threshold", "min_area", "model_size"],
advancedFields: ["threshold", "min_area"],
},
lpr: {
fieldOrder: [
"enabled",
"threshold",
"min_area",
"min_ratio",
"max_ratio",
"model_size",
],
advancedFields: ["threshold", "min_area", "min_ratio", "max_ratio"],
},
};
interface GlobalConfigSectionProps {
sectionKey: string;
schema: RJSFSchema | null;
config: FrigateConfig | undefined;
onSave: () => void;
}
function GlobalConfigSection({
sectionKey,
schema,
config,
onSave,
}: GlobalConfigSectionProps) {
const { t } = useTranslation(["views/settings"]);
const formData = useMemo((): Record<string, unknown> => {
if (!config) return {} as Record<string, unknown>;
const value = (config as unknown as Record<string, unknown>)[sectionKey];
return (
(value as Record<string, unknown>) || ({} as Record<string, unknown>)
);
}, [config, sectionKey]);
const handleSubmit = useCallback(
async (data: Record<string, unknown>) => {
try {
await axios.put("config/set", {
requires_restart: 1,
config_data: {
[sectionKey]: data,
},
});
toast.success(
t(`configForm.${sectionKey}.toast.success`, {
defaultValue: "Settings saved successfully",
}),
);
onSave();
} catch (error) {
toast.error(
t(`configForm.${sectionKey}.toast.error`, {
defaultValue: "Failed to save settings",
}),
);
}
},
[sectionKey, t, onSave],
);
if (!schema) {
return null;
}
const sectionConfig = globalSectionConfigs[sectionKey] || {};
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">
{t(`configForm.${sectionKey}.title`, {
defaultValue:
sectionKey.charAt(0).toUpperCase() + sectionKey.slice(1),
})}
</CardTitle>
</CardHeader>
<CardContent>
<ConfigForm
schema={schema}
formData={formData}
onSubmit={handleSubmit}
fieldOrder={sectionConfig.fieldOrder}
hiddenFields={sectionConfig.hiddenFields}
advancedFields={sectionConfig.advancedFields}
/>
</CardContent>
</Card>
);
}
export default function GlobalConfigView() {
const { t } = useTranslation(["views/settings"]);
const [activeTab, setActiveTab] = useState("shared");
const { data: config, mutate: refreshConfig } =
useSWR<FrigateConfig>("config");
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
const handleSave = useCallback(() => {
refreshConfig();
}, [refreshConfig]);
if (!config || !schema) {
return (
<div className="flex h-full items-center justify-center">
<ActivityIndicator />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">
{t("configForm.global.title", {
defaultValue: "Global Configuration",
})}
</h2>
<p className="text-muted-foreground">
{t("configForm.global.description", {
defaultValue:
"Configure global settings that apply to all cameras by default.",
})}
</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="shared">
{t("configForm.global.tabs.shared", {
defaultValue: "Shared Defaults",
})}
</TabsTrigger>
<TabsTrigger value="system">
{t("configForm.global.tabs.system", { defaultValue: "System" })}
</TabsTrigger>
<TabsTrigger value="integrations">
{t("configForm.global.tabs.integrations", {
defaultValue: "Integrations",
})}
</TabsTrigger>
</TabsList>
<ScrollArea className="h-[calc(100vh-300px)]">
<TabsContent value="shared" className="space-y-6 p-1">
{/* Shared config sections - these can be overridden per camera */}
<DetectSection level="global" onSave={handleSave} />
<RecordSection level="global" onSave={handleSave} />
<SnapshotsSection level="global" onSave={handleSave} />
<MotionSection level="global" onSave={handleSave} />
<ObjectsSection level="global" onSave={handleSave} />
<ReviewSection level="global" onSave={handleSave} />
<AudioSection level="global" onSave={handleSave} />
<NotificationsSection level="global" onSave={handleSave} />
<LiveSection level="global" onSave={handleSave} />
<TimestampSection level="global" onSave={handleSave} />
</TabsContent>
<TabsContent value="system" className="space-y-6 p-1">
{/* System configuration sections */}
<GlobalConfigSection
sectionKey="database"
schema={extractSchemaSection(schema, "database")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="tls"
schema={extractSchemaSection(schema, "tls")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="auth"
schema={extractSchemaSection(schema, "auth")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="telemetry"
schema={extractSchemaSection(schema, "telemetry")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="birdseye"
schema={extractSchemaSection(schema, "birdseye")}
config={config}
onSave={handleSave}
/>
</TabsContent>
<TabsContent value="integrations" className="space-y-6 p-1">
{/* Integration configuration sections */}
<GlobalConfigSection
sectionKey="mqtt"
schema={extractSchemaSection(schema, "mqtt")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="semantic_search"
schema={extractSchemaSection(schema, "semantic_search")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="face_recognition"
schema={extractSchemaSection(schema, "face_recognition")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="lpr"
schema={extractSchemaSection(schema, "lpr")}
config={config}
onSave={handleSave}
/>
</TabsContent>
</ScrollArea>
</Tabs>
</div>
);
}