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
+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;
}