2026-01-23 17:23:52 +03:00
|
|
|
// Base Section Component for config form sections
|
|
|
|
|
// Used as a foundation for reusable section components
|
|
|
|
|
|
2026-01-25 19:33:57 +03:00
|
|
|
import { useMemo, useCallback, useState, useEffect, useRef } from "react";
|
2026-01-23 17:23:52 +03:00
|
|
|
import useSWR from "swr";
|
|
|
|
|
import axios from "axios";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import { useTranslation } from "react-i18next";
|
2026-02-01 05:44:53 +03:00
|
|
|
import sectionRenderers, {
|
|
|
|
|
RendererComponent,
|
|
|
|
|
} from "@/components/config-form/sectionExtras/registry";
|
2026-01-23 17:23:52 +03:00
|
|
|
import { ConfigForm } from "../ConfigForm";
|
2026-01-24 20:06:08 +03:00
|
|
|
import type { UiSchema } from "@rjsf/utils";
|
2026-01-24 18:43:13 +03:00
|
|
|
import {
|
|
|
|
|
useConfigOverride,
|
|
|
|
|
normalizeConfigValue,
|
|
|
|
|
} from "@/hooks/use-config-override";
|
2026-01-23 17:23:52 +03:00
|
|
|
import { useSectionSchema } from "@/hooks/use-config-schema";
|
|
|
|
|
import type { FrigateConfig } from "@/types/frigateConfig";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2026-01-23 19:04:50 +03:00
|
|
|
import {
|
|
|
|
|
LuRotateCcw,
|
|
|
|
|
LuSave,
|
|
|
|
|
LuChevronDown,
|
|
|
|
|
LuChevronRight,
|
|
|
|
|
} from "react-icons/lu";
|
|
|
|
|
import Heading from "@/components/ui/heading";
|
2026-01-23 17:23:52 +03:00
|
|
|
import get from "lodash/get";
|
2026-01-24 18:43:13 +03:00
|
|
|
import unset from "lodash/unset";
|
|
|
|
|
import cloneDeep from "lodash/cloneDeep";
|
2026-01-23 19:04:50 +03:00
|
|
|
import isEqual from "lodash/isEqual";
|
|
|
|
|
import {
|
|
|
|
|
Collapsible,
|
|
|
|
|
CollapsibleContent,
|
|
|
|
|
CollapsibleTrigger,
|
|
|
|
|
} from "@/components/ui/collapsible";
|
2026-01-24 18:43:13 +03:00
|
|
|
import { applySchemaDefaults } from "@/lib/config-schema";
|
2026-01-30 20:41:55 +03:00
|
|
|
import { isJsonObject } from "@/lib/utils";
|
|
|
|
|
import { ConfigSectionData, JsonObject, JsonValue } from "@/types/configForm";
|
2026-01-23 17:23:52 +03:00
|
|
|
|
|
|
|
|
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[];
|
2026-01-25 19:33:57 +03:00
|
|
|
/** Fields to compare for override detection */
|
|
|
|
|
overrideFields?: string[];
|
2026-01-26 02:29:52 +03:00
|
|
|
/** Whether to enable live validation */
|
|
|
|
|
liveValidate?: boolean;
|
2026-01-24 20:06:08 +03:00
|
|
|
/** Additional uiSchema overrides */
|
|
|
|
|
uiSchema?: UiSchema;
|
2026-02-01 05:44:53 +03:00
|
|
|
/** Optional per-section renderers usable by FieldTemplate `ui:before`/`ui:after` */
|
|
|
|
|
renderers?: Record<string, RendererComponent>;
|
2026-01-23 17:23:52 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-01-23 19:04:50 +03:00
|
|
|
/** Whether section is collapsible */
|
|
|
|
|
collapsible?: boolean;
|
|
|
|
|
/** Default collapsed state */
|
|
|
|
|
defaultCollapsed?: boolean;
|
|
|
|
|
/** Whether to show the section title (default: false for global, true for camera) */
|
|
|
|
|
showTitle?: boolean;
|
2026-01-23 17:23:52 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CreateSectionOptions {
|
|
|
|
|
/** The config path for this section (e.g., "detect", "record") */
|
|
|
|
|
sectionPath: string;
|
|
|
|
|
/** Default section configuration */
|
|
|
|
|
defaultConfig: SectionConfig;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Factory function to create reusable config section components
|
|
|
|
|
*/
|
|
|
|
|
export function createConfigSection({
|
|
|
|
|
sectionPath,
|
|
|
|
|
defaultConfig,
|
|
|
|
|
}: CreateSectionOptions) {
|
2026-01-25 18:05:58 +03:00
|
|
|
const cameraUpdateTopicMap: Record<string, string> = {
|
|
|
|
|
detect: "detect",
|
|
|
|
|
record: "record",
|
|
|
|
|
snapshots: "snapshots",
|
|
|
|
|
motion: "motion",
|
|
|
|
|
objects: "objects",
|
|
|
|
|
review: "review",
|
|
|
|
|
audio: "audio",
|
|
|
|
|
notifications: "notifications",
|
2026-01-25 19:33:57 +03:00
|
|
|
live: "live",
|
|
|
|
|
timestamp_style: "timestamp_style",
|
|
|
|
|
audio_transcription: "audio_transcription",
|
|
|
|
|
birdseye: "birdseye",
|
|
|
|
|
face_recognition: "face_recognition",
|
|
|
|
|
ffmpeg: "ffmpeg",
|
|
|
|
|
lpr: "lpr",
|
|
|
|
|
semantic_search: "semantic_search",
|
|
|
|
|
mqtt: "mqtt",
|
|
|
|
|
onvif: "onvif",
|
|
|
|
|
ui: "ui",
|
2026-01-25 18:05:58 +03:00
|
|
|
};
|
|
|
|
|
|
2026-01-23 19:04:50 +03:00
|
|
|
const ConfigSection = function ConfigSection({
|
2026-01-23 17:23:52 +03:00
|
|
|
level,
|
|
|
|
|
cameraName,
|
|
|
|
|
showOverrideIndicator = true,
|
|
|
|
|
sectionConfig = defaultConfig,
|
|
|
|
|
disabled = false,
|
|
|
|
|
readonly = false,
|
|
|
|
|
onSave,
|
|
|
|
|
requiresRestart = true,
|
2026-01-23 19:04:50 +03:00
|
|
|
collapsible = false,
|
|
|
|
|
defaultCollapsed = false,
|
|
|
|
|
showTitle,
|
2026-01-23 17:23:52 +03:00
|
|
|
}: BaseSectionProps) {
|
2026-01-31 17:50:24 +03:00
|
|
|
const { t, i18n } = useTranslation([
|
2026-01-31 19:18:38 +03:00
|
|
|
level === "camera" ? "config/cameras" : "config/global",
|
2026-01-31 17:50:24 +03:00
|
|
|
"config/cameras",
|
|
|
|
|
"views/settings",
|
|
|
|
|
"common",
|
|
|
|
|
]);
|
2026-01-23 19:04:50 +03:00
|
|
|
const [isOpen, setIsOpen] = useState(!defaultCollapsed);
|
2026-01-30 20:41:55 +03:00
|
|
|
const [pendingData, setPendingData] = useState<ConfigSectionData | null>(
|
|
|
|
|
null,
|
|
|
|
|
);
|
2026-01-23 19:04:50 +03:00
|
|
|
const [isSaving, setIsSaving] = useState(false);
|
2026-01-25 19:33:57 +03:00
|
|
|
const [formKey, setFormKey] = useState(0);
|
|
|
|
|
const isResettingRef = useRef(false);
|
2026-01-23 19:04:50 +03:00
|
|
|
|
2026-01-25 18:05:58 +03:00
|
|
|
const updateTopic =
|
|
|
|
|
level === "camera" && cameraName
|
|
|
|
|
? cameraUpdateTopicMap[sectionPath]
|
|
|
|
|
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
|
|
|
|
|
: undefined
|
|
|
|
|
: `config/${sectionPath}`;
|
|
|
|
|
|
2026-01-23 19:04:50 +03:00
|
|
|
// Default: show title for camera level (since it might be collapsible), hide for global
|
|
|
|
|
const shouldShowTitle = showTitle ?? level === "camera";
|
2026-01-23 17:23:52 +03:00
|
|
|
|
|
|
|
|
// Fetch config
|
|
|
|
|
const { data: config, mutate: refreshConfig } =
|
|
|
|
|
useSWR<FrigateConfig>("config");
|
|
|
|
|
|
|
|
|
|
// Get section schema using cached hook
|
|
|
|
|
const sectionSchema = useSectionSchema(sectionPath, level);
|
|
|
|
|
|
|
|
|
|
// Get override status
|
2026-01-23 19:04:50 +03:00
|
|
|
const { isOverridden, globalValue, cameraValue } = useConfigOverride({
|
|
|
|
|
config,
|
|
|
|
|
cameraName: level === "camera" ? cameraName : undefined,
|
|
|
|
|
sectionPath,
|
2026-01-25 19:33:57 +03:00
|
|
|
compareFields: sectionConfig.overrideFields,
|
2026-01-23 19:04:50 +03:00
|
|
|
});
|
2026-01-23 17:23:52 +03:00
|
|
|
|
|
|
|
|
// Get current form data
|
2026-01-24 18:43:13 +03:00
|
|
|
const rawFormData = useMemo(() => {
|
2026-01-23 17:23:52 +03:00
|
|
|
if (!config) return {};
|
|
|
|
|
|
|
|
|
|
if (level === "camera" && cameraName) {
|
|
|
|
|
return get(config.cameras?.[cameraName], sectionPath) || {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return get(config, sectionPath) || {};
|
|
|
|
|
}, [config, level, cameraName]);
|
|
|
|
|
|
2026-01-24 18:43:13 +03:00
|
|
|
const sanitizeSectionData = useCallback(
|
2026-01-30 20:41:55 +03:00
|
|
|
(data: ConfigSectionData) => {
|
|
|
|
|
const normalized = normalizeConfigValue(data) as ConfigSectionData;
|
2026-01-24 18:43:13 +03:00
|
|
|
if (
|
|
|
|
|
!sectionConfig.hiddenFields ||
|
|
|
|
|
sectionConfig.hiddenFields.length === 0
|
|
|
|
|
) {
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 20:41:55 +03:00
|
|
|
const cleaned = cloneDeep(normalized) as ConfigSectionData;
|
2026-01-24 18:43:13 +03:00
|
|
|
sectionConfig.hiddenFields.forEach((path) => {
|
|
|
|
|
if (!path) return;
|
|
|
|
|
unset(cleaned, path);
|
|
|
|
|
});
|
|
|
|
|
return cleaned;
|
|
|
|
|
},
|
|
|
|
|
[sectionConfig.hiddenFields],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const formData = useMemo(() => {
|
|
|
|
|
const baseData = sectionSchema
|
|
|
|
|
? applySchemaDefaults(sectionSchema, rawFormData)
|
|
|
|
|
: rawFormData;
|
|
|
|
|
return sanitizeSectionData(baseData);
|
|
|
|
|
}, [rawFormData, sectionSchema, sanitizeSectionData]);
|
|
|
|
|
|
2026-01-24 20:59:55 +03:00
|
|
|
const schemaDefaults = useMemo(() => {
|
|
|
|
|
if (!sectionSchema) {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
return applySchemaDefaults(sectionSchema, {});
|
|
|
|
|
}, [sectionSchema]);
|
|
|
|
|
|
2026-01-25 19:33:57 +03:00
|
|
|
// Clear pendingData whenever formData changes (e.g., from server refresh)
|
|
|
|
|
// This prevents RJSF's initial onChange call from being treated as a user edit
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setPendingData(null);
|
|
|
|
|
}, [formData]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isResettingRef.current) {
|
|
|
|
|
isResettingRef.current = false;
|
|
|
|
|
}
|
|
|
|
|
}, [formKey]);
|
|
|
|
|
|
2026-01-31 20:03:31 +03:00
|
|
|
// Build a minimal overrides payload by comparing `current` against `base`
|
|
|
|
|
// (existing config) and `defaults` (schema defaults).
|
|
|
|
|
// - Returns `undefined` for null/empty values or when `current` equals `base`
|
|
|
|
|
// (or equals `defaults` when `base` is undefined).
|
|
|
|
|
// - For objects, recurses and returns an object containing only keys that
|
|
|
|
|
// are overridden; returns `undefined` if no keys are overridden.
|
2026-01-24 20:59:55 +03:00
|
|
|
const buildOverrides = useCallback(
|
|
|
|
|
(
|
|
|
|
|
current: unknown,
|
|
|
|
|
base: unknown,
|
|
|
|
|
defaults: unknown,
|
|
|
|
|
): unknown | undefined => {
|
|
|
|
|
if (current === null || current === undefined || current === "") {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(current)) {
|
|
|
|
|
if (
|
|
|
|
|
(base === undefined &&
|
|
|
|
|
defaults !== undefined &&
|
|
|
|
|
isEqual(current, defaults)) ||
|
|
|
|
|
isEqual(current, base)
|
|
|
|
|
) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
return current;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 20:41:55 +03:00
|
|
|
if (isJsonObject(current)) {
|
|
|
|
|
const currentObj = current;
|
|
|
|
|
const baseObj = isJsonObject(base) ? base : undefined;
|
|
|
|
|
const defaultsObj = isJsonObject(defaults) ? defaults : undefined;
|
2026-01-24 20:59:55 +03:00
|
|
|
|
2026-01-30 20:41:55 +03:00
|
|
|
const result: JsonObject = {};
|
2026-01-24 20:59:55 +03:00
|
|
|
for (const [key, value] of Object.entries(currentObj)) {
|
|
|
|
|
const overrideValue = buildOverrides(
|
|
|
|
|
value,
|
|
|
|
|
baseObj ? baseObj[key] : undefined,
|
|
|
|
|
defaultsObj ? defaultsObj[key] : undefined,
|
|
|
|
|
);
|
|
|
|
|
if (overrideValue !== undefined) {
|
2026-01-30 20:41:55 +03:00
|
|
|
result[key] = overrideValue as JsonValue;
|
2026-01-24 20:59:55 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
base === undefined &&
|
|
|
|
|
defaults !== undefined &&
|
|
|
|
|
isEqual(current, defaults)
|
|
|
|
|
) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isEqual(current, base)) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return current;
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-23 19:04:50 +03:00
|
|
|
// Track if there are unsaved changes
|
|
|
|
|
const hasChanges = useMemo(() => {
|
|
|
|
|
if (!pendingData) return false;
|
|
|
|
|
return !isEqual(formData, pendingData);
|
|
|
|
|
}, [formData, pendingData]);
|
|
|
|
|
|
|
|
|
|
// Handle form data change
|
2026-01-24 18:43:13 +03:00
|
|
|
const handleChange = useCallback(
|
2026-01-25 19:33:57 +03:00
|
|
|
(data: unknown) => {
|
|
|
|
|
if (isResettingRef.current) {
|
|
|
|
|
setPendingData(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!data || typeof data !== "object") {
|
|
|
|
|
setPendingData(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-30 20:41:55 +03:00
|
|
|
const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
|
2026-01-24 18:43:13 +03:00
|
|
|
if (isEqual(formData, sanitizedData)) {
|
|
|
|
|
setPendingData(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setPendingData(sanitizedData);
|
|
|
|
|
},
|
|
|
|
|
[formData, sanitizeSectionData],
|
|
|
|
|
);
|
2026-01-23 19:04:50 +03:00
|
|
|
|
2026-01-25 19:33:57 +03:00
|
|
|
const handleReset = useCallback(() => {
|
|
|
|
|
isResettingRef.current = true;
|
|
|
|
|
setPendingData(null);
|
|
|
|
|
setFormKey((prev) => prev + 1);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-01-23 19:04:50 +03:00
|
|
|
// Handle save button click
|
|
|
|
|
const handleSave = useCallback(async () => {
|
|
|
|
|
if (!pendingData) return;
|
2026-01-23 17:23:52 +03:00
|
|
|
|
2026-01-23 19:04:50 +03:00
|
|
|
setIsSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
const basePath =
|
|
|
|
|
level === "camera" && cameraName
|
|
|
|
|
? `cameras.${cameraName}.${sectionPath}`
|
|
|
|
|
: sectionPath;
|
|
|
|
|
|
2026-01-24 20:59:55 +03:00
|
|
|
const rawData = sanitizeSectionData(rawFormData);
|
|
|
|
|
const overrides = buildOverrides(pendingData, rawData, schemaDefaults);
|
|
|
|
|
|
|
|
|
|
if (!overrides || Object.keys(overrides).length === 0) {
|
|
|
|
|
setPendingData(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 20:01:08 +03:00
|
|
|
// await axios.put("config/set", {
|
|
|
|
|
// requires_restart: requiresRestart ? 0 : 1,
|
|
|
|
|
// update_topic: updateTopic,
|
|
|
|
|
// config_data: {
|
|
|
|
|
// [basePath]: overrides,
|
|
|
|
|
// },
|
|
|
|
|
// });
|
|
|
|
|
|
|
|
|
|
// log save to console for debugging
|
|
|
|
|
console.log("Saved config data:", {
|
|
|
|
|
[basePath]: overrides,
|
2026-01-25 18:05:58 +03:00
|
|
|
update_topic: updateTopic,
|
2026-01-28 20:01:08 +03:00
|
|
|
requires_restart: requiresRestart ? 0 : 1,
|
2026-01-23 19:04:50 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
toast.success(
|
|
|
|
|
t("toast.success", {
|
|
|
|
|
ns: "views/settings",
|
|
|
|
|
defaultValue: "Settings saved successfully",
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
setPendingData(null);
|
|
|
|
|
refreshConfig();
|
|
|
|
|
onSave?.();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// Parse Pydantic validation errors from API response
|
|
|
|
|
if (axios.isAxiosError(error) && error.response?.data) {
|
|
|
|
|
const responseData = error.response.data;
|
|
|
|
|
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("toast.validationError", {
|
|
|
|
|
ns: "views/settings",
|
|
|
|
|
defaultValue: `Validation failed: ${validationMessages}`,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
} else if (responseData.message) {
|
|
|
|
|
toast.error(responseData.message);
|
2026-01-23 17:23:52 +03:00
|
|
|
} else {
|
|
|
|
|
toast.error(
|
2026-01-23 19:04:50 +03:00
|
|
|
t("toast.error", {
|
|
|
|
|
ns: "views/settings",
|
2026-01-23 17:23:52 +03:00
|
|
|
defaultValue: "Failed to save settings",
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-01-23 19:04:50 +03:00
|
|
|
} else {
|
|
|
|
|
toast.error(
|
|
|
|
|
t("toast.error", {
|
|
|
|
|
ns: "views/settings",
|
|
|
|
|
defaultValue: "Failed to save settings",
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-01-23 17:23:52 +03:00
|
|
|
}
|
2026-01-23 19:04:50 +03:00
|
|
|
} finally {
|
|
|
|
|
setIsSaving(false);
|
|
|
|
|
}
|
|
|
|
|
}, [
|
|
|
|
|
pendingData,
|
|
|
|
|
level,
|
|
|
|
|
cameraName,
|
|
|
|
|
requiresRestart,
|
|
|
|
|
t,
|
|
|
|
|
refreshConfig,
|
|
|
|
|
onSave,
|
2026-01-24 20:59:55 +03:00
|
|
|
rawFormData,
|
|
|
|
|
sanitizeSectionData,
|
|
|
|
|
buildOverrides,
|
|
|
|
|
schemaDefaults,
|
2026-01-25 18:05:58 +03:00
|
|
|
updateTopic,
|
2026-01-23 19:04:50 +03:00
|
|
|
]);
|
2026-01-23 17:23:52 +03:00
|
|
|
|
2026-02-01 05:44:53 +03:00
|
|
|
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
|
2026-01-23 17:23:52 +03:00
|
|
|
const handleResetToGlobal = useCallback(async () => {
|
2026-02-01 05:44:53 +03:00
|
|
|
if (level === "camera" && !cameraName) return;
|
2026-01-23 17:23:52 +03:00
|
|
|
|
|
|
|
|
try {
|
2026-02-01 05:44:53 +03:00
|
|
|
const basePath =
|
|
|
|
|
level === "camera" && cameraName
|
|
|
|
|
? `cameras.${cameraName}.${sectionPath}`
|
|
|
|
|
: sectionPath;
|
|
|
|
|
|
|
|
|
|
// const configData = level === "global" ? schemaDefaults : "";
|
2026-01-23 17:23:52 +03:00
|
|
|
|
2026-01-28 20:01:08 +03:00
|
|
|
// await axios.put("config/set", {
|
|
|
|
|
// requires_restart: requiresRestart ? 0 : 1,
|
|
|
|
|
// update_topic: updateTopic,
|
|
|
|
|
// config_data: {
|
2026-02-01 05:44:53 +03:00
|
|
|
// [basePath]: configData,
|
2026-01-28 20:01:08 +03:00
|
|
|
// },
|
|
|
|
|
// });
|
|
|
|
|
|
|
|
|
|
// log reset to console for debugging
|
2026-02-01 05:44:53 +03:00
|
|
|
console.log(
|
|
|
|
|
level === "global"
|
|
|
|
|
? "Reset to defaults for path:"
|
|
|
|
|
: "Reset to global config for path:",
|
|
|
|
|
basePath,
|
|
|
|
|
{
|
|
|
|
|
update_topic: updateTopic,
|
|
|
|
|
requires_restart: requiresRestart ? 0 : 1,
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-01-23 17:23:52 +03:00
|
|
|
|
|
|
|
|
toast.success(
|
2026-01-23 19:04:50 +03:00
|
|
|
t("toast.resetSuccess", {
|
|
|
|
|
ns: "views/settings",
|
2026-02-01 05:44:53 +03:00
|
|
|
defaultValue:
|
|
|
|
|
level === "global"
|
|
|
|
|
? "Reset to defaults"
|
|
|
|
|
: "Reset to global defaults",
|
2026-01-23 17:23:52 +03:00
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-23 19:04:50 +03:00
|
|
|
setPendingData(null);
|
2026-01-23 17:23:52 +03:00
|
|
|
refreshConfig();
|
|
|
|
|
} catch {
|
|
|
|
|
toast.error(
|
2026-01-23 19:04:50 +03:00
|
|
|
t("toast.resetError", {
|
|
|
|
|
ns: "views/settings",
|
2026-01-23 17:23:52 +03:00
|
|
|
defaultValue: "Failed to reset settings",
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-01-25 18:05:58 +03:00
|
|
|
}, [level, cameraName, requiresRestart, t, refreshConfig, updateTopic]);
|
2026-01-23 17:23:52 +03:00
|
|
|
|
|
|
|
|
if (!sectionSchema) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-31 18:58:10 +03:00
|
|
|
// Get section title from config namespace
|
2026-01-31 17:50:24 +03:00
|
|
|
const defaultTitle =
|
|
|
|
|
sectionPath.charAt(0).toUpperCase() +
|
|
|
|
|
sectionPath.slice(1).replace(/_/g, " ");
|
2026-01-31 18:58:10 +03:00
|
|
|
|
|
|
|
|
// For camera-level sections, keys live under `config/cameras` and are
|
|
|
|
|
// nested under the section name (e.g., `audio.label`). For global-level
|
|
|
|
|
// sections, keys are nested under the section name in `config/global`.
|
|
|
|
|
const configNamespace =
|
|
|
|
|
level === "camera" ? "config/cameras" : "config/global";
|
|
|
|
|
const title = t(`${sectionPath}.label`, {
|
|
|
|
|
ns: configNamespace,
|
|
|
|
|
defaultValue: defaultTitle,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const sectionDescription = i18n.exists(`${sectionPath}.description`, {
|
|
|
|
|
ns: configNamespace,
|
|
|
|
|
})
|
|
|
|
|
? t(`${sectionPath}.description`, { ns: configNamespace })
|
|
|
|
|
: undefined;
|
2026-01-23 17:23:52 +03:00
|
|
|
|
2026-01-23 19:04:50 +03:00
|
|
|
const sectionContent = (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<ConfigForm
|
2026-01-25 19:33:57 +03:00
|
|
|
key={formKey}
|
2026-01-23 19:04:50 +03:00
|
|
|
schema={sectionSchema}
|
|
|
|
|
formData={pendingData || formData}
|
|
|
|
|
onChange={handleChange}
|
|
|
|
|
fieldOrder={sectionConfig.fieldOrder}
|
2026-01-26 02:29:52 +03:00
|
|
|
fieldGroups={sectionConfig.fieldGroups}
|
2026-01-23 19:04:50 +03:00
|
|
|
hiddenFields={sectionConfig.hiddenFields}
|
|
|
|
|
advancedFields={sectionConfig.advancedFields}
|
2026-01-26 02:29:52 +03:00
|
|
|
liveValidate={sectionConfig.liveValidate}
|
2026-01-24 20:06:08 +03:00
|
|
|
uiSchema={sectionConfig.uiSchema}
|
2026-01-23 19:04:50 +03:00
|
|
|
disabled={disabled || isSaving}
|
|
|
|
|
readonly={readonly}
|
|
|
|
|
showSubmit={false}
|
2026-01-31 18:58:10 +03:00
|
|
|
i18nNamespace={configNamespace}
|
2026-01-23 19:04:50 +03:00
|
|
|
formContext={{
|
|
|
|
|
level,
|
|
|
|
|
cameraName,
|
|
|
|
|
globalValue,
|
|
|
|
|
cameraValue,
|
2026-01-24 20:06:08 +03:00
|
|
|
// For widgets that need access to full camera config (e.g., zone names)
|
|
|
|
|
fullCameraConfig:
|
|
|
|
|
level === "camera" && cameraName
|
|
|
|
|
? config?.cameras?.[cameraName]
|
|
|
|
|
: undefined,
|
2026-01-30 17:52:11 +03:00
|
|
|
fullConfig: config,
|
2026-01-31 17:50:24 +03:00
|
|
|
// When rendering camera-level sections, provide the section path so
|
|
|
|
|
// field templates can look up keys under the `config/cameras` namespace
|
2026-01-31 18:58:10 +03:00
|
|
|
// When using a consolidated global namespace, keys are nested
|
|
|
|
|
// under the section name (e.g., `audio.label`) so provide the
|
|
|
|
|
// section prefix to templates so they can attempt `${section}.${field}` lookups.
|
|
|
|
|
sectionI18nPrefix: sectionPath,
|
2026-01-24 20:06:08 +03:00
|
|
|
t,
|
2026-02-01 05:44:53 +03:00
|
|
|
renderers:
|
|
|
|
|
sectionConfig?.renderers ?? sectionRenderers?.[sectionPath],
|
2026-01-23 19:04:50 +03:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Save button */}
|
|
|
|
|
<div className="flex items-center justify-between pt-2">
|
2026-01-23 17:23:52 +03:00
|
|
|
<div className="flex items-center gap-2">
|
2026-01-23 19:04:50 +03:00
|
|
|
{hasChanges && (
|
|
|
|
|
<span className="text-sm text-muted-foreground">
|
|
|
|
|
{t("unsavedChanges", {
|
|
|
|
|
ns: "views/settings",
|
|
|
|
|
defaultValue: "You have unsaved changes",
|
|
|
|
|
})}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-01-25 19:33:57 +03:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{hasChanges && (
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleReset}
|
|
|
|
|
variant="outline"
|
|
|
|
|
disabled={isSaving || disabled}
|
|
|
|
|
className="gap-2"
|
|
|
|
|
>
|
|
|
|
|
{t("reset", { ns: "common", defaultValue: "Reset" })}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleSave}
|
2026-01-28 20:01:08 +03:00
|
|
|
variant="select"
|
2026-01-25 19:33:57 +03:00
|
|
|
disabled={!hasChanges || isSaving || disabled}
|
|
|
|
|
className="gap-2"
|
|
|
|
|
>
|
|
|
|
|
<LuSave className="h-4 w-4" />
|
|
|
|
|
{isSaving
|
|
|
|
|
? t("saving", { ns: "common", defaultValue: "Saving..." })
|
|
|
|
|
: t("save", { ns: "common", defaultValue: "Save" })}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-01-23 19:04:50 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (collapsible) {
|
|
|
|
|
return (
|
|
|
|
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<CollapsibleTrigger asChild>
|
|
|
|
|
<div className="flex cursor-pointer items-center justify-between">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
{isOpen ? (
|
|
|
|
|
<LuChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
) : (
|
|
|
|
|
<LuChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
)}
|
|
|
|
|
<Heading as="h4">{title}</Heading>
|
|
|
|
|
{showOverrideIndicator &&
|
|
|
|
|
level === "camera" &&
|
|
|
|
|
isOverridden && (
|
|
|
|
|
<Badge variant="secondary" className="text-xs">
|
|
|
|
|
{t("overridden", {
|
|
|
|
|
ns: "common",
|
|
|
|
|
defaultValue: "Overridden",
|
|
|
|
|
})}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{hasChanges && (
|
|
|
|
|
<Badge variant="outline" className="text-xs">
|
|
|
|
|
{t("modified", {
|
|
|
|
|
ns: "common",
|
|
|
|
|
defaultValue: "Modified",
|
|
|
|
|
})}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-02-01 05:44:53 +03:00
|
|
|
{((level === "camera" && isOverridden) ||
|
|
|
|
|
level === "global") && (
|
2026-01-23 19:04:50 +03:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
handleResetToGlobal();
|
|
|
|
|
}}
|
|
|
|
|
className="gap-2"
|
|
|
|
|
>
|
|
|
|
|
<LuRotateCcw className="h-4 w-4" />
|
2026-02-01 05:44:53 +03:00
|
|
|
{level === "global"
|
|
|
|
|
? t("button.resetToDefault", {
|
|
|
|
|
ns: "common",
|
|
|
|
|
defaultValue: "Reset to Default",
|
|
|
|
|
})
|
|
|
|
|
: t("button.resetToGlobal", {
|
|
|
|
|
ns: "common",
|
|
|
|
|
defaultValue: "Reset to Global",
|
|
|
|
|
})}
|
2026-01-23 19:04:50 +03:00
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CollapsibleTrigger>
|
|
|
|
|
|
|
|
|
|
<CollapsibleContent>
|
|
|
|
|
<div className="pl-7">{sectionContent}</div>
|
|
|
|
|
</CollapsibleContent>
|
|
|
|
|
</div>
|
|
|
|
|
</Collapsible>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{shouldShowTitle && (
|
2026-01-31 17:50:24 +03:00
|
|
|
<div className="flex items-start justify-between">
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Heading as="h4">{title}</Heading>
|
|
|
|
|
{showOverrideIndicator &&
|
|
|
|
|
level === "camera" &&
|
|
|
|
|
isOverridden && (
|
|
|
|
|
<Badge variant="secondary" className="text-xs">
|
|
|
|
|
{t("overridden", {
|
|
|
|
|
ns: "common",
|
|
|
|
|
defaultValue: "Overridden",
|
|
|
|
|
})}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{hasChanges && (
|
|
|
|
|
<Badge variant="outline" className="text-xs">
|
|
|
|
|
{t("modified", { ns: "common", defaultValue: "Modified" })}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{sectionDescription && (
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
{sectionDescription}
|
|
|
|
|
</p>
|
2026-01-23 19:04:50 +03:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-02-01 05:44:53 +03:00
|
|
|
{((level === "camera" && isOverridden) || level === "global") && (
|
2026-01-23 19:04:50 +03:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleResetToGlobal}
|
|
|
|
|
className="gap-2"
|
|
|
|
|
>
|
|
|
|
|
<LuRotateCcw className="h-4 w-4" />
|
2026-02-01 05:44:53 +03:00
|
|
|
{level === "global"
|
|
|
|
|
? t("button.resetToDefault", {
|
|
|
|
|
ns: "common",
|
|
|
|
|
defaultValue: "Reset to Default",
|
|
|
|
|
})
|
|
|
|
|
: t("button.resetToGlobal", {
|
|
|
|
|
ns: "common",
|
|
|
|
|
defaultValue: "Reset to Global",
|
|
|
|
|
})}
|
2026-01-23 19:04:50 +03:00
|
|
|
</Button>
|
2026-01-23 17:23:52 +03:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-01-23 19:04:50 +03:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Reset button when title is hidden but we're at camera level with override */}
|
2026-02-01 05:44:53 +03:00
|
|
|
{!shouldShowTitle &&
|
|
|
|
|
((level === "camera" && isOverridden) || level === "global") && (
|
|
|
|
|
<div className="flex justify-end">
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleResetToGlobal}
|
|
|
|
|
className="gap-2"
|
|
|
|
|
>
|
|
|
|
|
<LuRotateCcw className="h-4 w-4" />
|
|
|
|
|
{level === "global"
|
|
|
|
|
? t("button.resetToDefault", {
|
|
|
|
|
ns: "common",
|
|
|
|
|
defaultValue: "Reset to Default",
|
|
|
|
|
})
|
|
|
|
|
: t("button.resetToGlobal", {
|
|
|
|
|
ns: "common",
|
|
|
|
|
defaultValue: "Reset to Global",
|
|
|
|
|
})}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-23 19:04:50 +03:00
|
|
|
|
|
|
|
|
{sectionContent}
|
|
|
|
|
</div>
|
2026-01-23 17:23:52 +03:00
|
|
|
);
|
|
|
|
|
};
|
2026-01-23 19:04:50 +03:00
|
|
|
|
|
|
|
|
return ConfigSection;
|
2026-01-23 17:23:52 +03:00
|
|
|
}
|