From 95a0530ce69f3c6a1e4f2a2ab30d7bfcd593ccd8 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 1 Feb 2026 15:30:46 -0600 Subject: [PATCH] re-add override and modified indicators --- .../config-form/sections/BaseSection.tsx | 45 ++++-- web/src/pages/Settings.tsx | 153 +++++++++++++++++- web/src/views/settings/SingleSectionPage.tsx | 39 ++++- 3 files changed, 214 insertions(+), 23 deletions(-) diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index d83cc35f2..118f9a2a2 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -87,6 +87,11 @@ export interface BaseSectionProps { defaultCollapsed?: boolean; /** Whether to show the section title (default: false for global, true for camera) */ showTitle?: boolean; + /** Callback when section status changes */ + onStatusChange?: (status: { + hasChanges: boolean; + isOverridden: boolean; + }) => void; } export interface CreateSectionOptions { @@ -134,6 +139,7 @@ export function ConfigSection({ collapsible = false, defaultCollapsed = false, showTitle, + onStatusChange, }: ConfigSectionProps) { const { t, i18n } = useTranslation([ level === "camera" ? "config/cameras" : "config/global", @@ -301,6 +307,10 @@ export function ConfigSection({ return !isEqual(formData, pendingData); }, [formData, pendingData]); + useEffect(() => { + onStatusChange?.({ hasChanges, isOverridden }); + }, [hasChanges, isOverridden, onStatusChange]); + // Handle form data change const handleChange = useCallback( (data: unknown) => { @@ -346,14 +356,15 @@ export function ConfigSection({ return; } - // await axios.put("config/set", { - // requires_restart: requiresRestart ? 0 : 1, - // update_topic: updateTopic, - // config_data: { - // [basePath]: overrides, - // }, - // }); + await axios.put("config/sett", { + requires_restart: requiresRestart ? 0 : 1, + update_topic: updateTopic, + config_data: { + [basePath]: overrides, + }, + }); // log save to console for debugging + // eslint-disable-next-line no-console console.log("Saved config data:", { [basePath]: overrides, update_topic: updateTopic, @@ -432,17 +443,20 @@ export function ConfigSection({ const basePath = level === "camera" && cameraName ? `cameras.${cameraName}.${sectionPath}` - : sectionPath; // const configData = level === "global" ? schemaDefaults : ""; + : sectionPath; - // await axios.put("config/set", { - // requires_restart: requiresRestart ? 0 : 1, - // update_topic: updateTopic, - // config_data: { - // [basePath]: configData, - // }, - // }); + const configData = level === "global" ? schemaDefaults : ""; + + await axios.put("config/sett", { + requires_restart: requiresRestart ? 0 : 1, + update_topic: updateTopic, + config_data: { + [basePath]: configData, + }, + }); // log reset to console for debugging + // eslint-disable-next-line no-console console.log( level === "global" ? "Reset to defaults for path:" @@ -474,6 +488,7 @@ export function ConfigSection({ ); } }, [ + schemaDefaults, sectionPath, level, cameraName, diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index bc7ad3fb6..684ee7e0c 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -17,7 +17,13 @@ import { } from "@/components/ui/alert-dialog"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import { Button } from "@/components/ui/button"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import useOptimisticState from "@/hooks/use-optimistic-state"; import { isMobile } from "react-device-detect"; import { FaVideo } from "react-icons/fa"; @@ -39,12 +45,14 @@ import MaintenanceSettingsView from "@/views/settings/MaintenanceSettingsView"; import { SingleSectionPage, type SettingsPageProps, + type SectionStatus, } from "@/views/settings/SingleSectionPage"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useInitialCameraState } from "@/api/ws"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { useTranslation } from "react-i18next"; +import { useAllCameraOverrides } from "@/hooks/use-config-override"; import TriggerView from "@/views/settings/TriggerView"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { @@ -420,6 +428,45 @@ const CAMERA_SELECT_BUTTON_PAGES = [ const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"]; +// keys for camera sections +const CAMERA_SECTION_MAPPING: Record = { + detect: "cameraDetect", + ffmpeg: "cameraFfmpeg", + record: "cameraRecording", + snapshots: "cameraSnapshots", + motion: "cameraMotion", + objects: "cameraObjects", + review: "cameraConfigReview", + audio: "cameraAudioEvents", + audio_transcription: "cameraAudioTranscription", + notifications: "cameraNotifications", + live: "cameraLivePlayback", + birdseye: "cameraBirdseye", + face_recognition: "cameraFaceRecognition", + lpr: "cameraLpr", + mqtt: "cameraMqttConfig", + onvif: "cameraOnvif", + ui: "cameraUi", + timestamp_style: "cameraTimestampStyle", +}; + +// keys for global sections +const GLOBAL_SECTION_MAPPING: Record = { + detect: "globalDetect", + record: "globalRecording", + snapshots: "globalSnapshots", + motion: "globalMotion", + objects: "globalObjects", + review: "globalReview", + audio: "globalAudioEvents", + live: "globalLivePlayback", + timestamp_style: "globalTimestampStyle", +}; + +const CAMERA_SECTION_KEYS = new Set( + Object.values(CAMERA_SECTION_MAPPING), +); + const getCurrentComponent = (page: SettingsType) => { for (const group of settingsGroups) { for (const item of group.items) { @@ -436,11 +483,13 @@ function MobileMenuItem({ onSelect, onClose, className, + label, }: { item: { key: string }; onSelect: (key: string) => void; onClose?: () => void; className?: string; + label?: ReactNode; }) { const { t } = useTranslation(["views/settings"]); @@ -455,7 +504,11 @@ function MobileMenuItem({ onClose?.(); }} > -
{t("menu." + item.key)}
+
+ {label ?? ( +
{t("menu." + item.key)}
+ )} +
); @@ -466,6 +519,9 @@ export default function Settings() { const [page, setPage] = useState("profileSettings"); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); const [contentMobileOpen, setContentMobileOpen] = useState(false); + const [sectionStatusByKey, setSectionStatusByKey] = useState< + Partial> + >({}); const { data: config } = useSWR("config"); @@ -497,6 +553,9 @@ export default function Settings() { const [selectedCamera, setSelectedCamera] = useState(""); + // Get all camera overrides for the selected camera + const cameraOverrides = useAllCameraOverrides(config, selectedCamera); + const { payload: allCameraStates } = useInitialCameraState( cameras.length > 0 ? cameras[0].name : "", true, @@ -589,6 +648,81 @@ export default function Settings() { } }, [t, contentMobileOpen]); + const handleSectionStatusChange = useCallback( + (sectionKey: string, level: "global" | "camera", status: SectionStatus) => { + // Map section keys to menu keys based on level + let menuKey: string; + if (level === "camera") { + menuKey = CAMERA_SECTION_MAPPING[sectionKey] || sectionKey; + } else { + menuKey = GLOBAL_SECTION_MAPPING[sectionKey] || sectionKey; + } + + setSectionStatusByKey((prev) => ({ + ...prev, + [menuKey]: status, + })); + }, + [], + ); + + // Initialize override status for all camera sections + useEffect(() => { + if (!selectedCamera || !cameraOverrides) return; + + const overrideMap: Partial> = {}; + + // Set override status for all camera sections using the shared mapping + Object.entries(CAMERA_SECTION_MAPPING).forEach( + ([sectionKey, settingsKey]) => { + const isOverridden = cameraOverrides.includes(sectionKey); + overrideMap[settingsKey] = { + hasChanges: false, + isOverridden, + }; + }, + ); + + setSectionStatusByKey((prev) => { + // Merge but preserve hasChanges from previous state + const merged = { ...prev }; + Object.entries(overrideMap).forEach(([key, status]) => { + merged[key as SettingsType] = { + hasChanges: prev[key as SettingsType]?.hasChanges || false, + isOverridden: status.isOverridden, + }; + }); + return merged; + }); + }, [selectedCamera, cameraOverrides]); + + const renderMenuItemLabel = useCallback( + (key: SettingsType) => { + const status = sectionStatusByKey[key]; + const showOverrideDot = + CAMERA_SECTION_KEYS.has(key) && status?.isOverridden; + // const showUnsavedDot = status?.hasChanges; + const showUnsavedDot = false; // Disable unsaved changes indicator for now + + return ( +
+
{t("menu." + key)}
+ {(showOverrideDot || showUnsavedDot) && ( +
+ {showOverrideDot && ( + + )} + {showUnsavedDot && ( + + )} +
+ )} +
+ ); + }, + [sectionStatusByKey, t], + ); + if (isMobile) { return ( <> @@ -625,6 +759,7 @@ export default function Settings() { key={item.key} item={item} className={cn(filteredItems.length == 1 && "pl-2")} + label={renderMenuItemLabel(item.key as SettingsType)} onSelect={(key) => { if ( !isAdmin && @@ -688,6 +823,7 @@ export default function Settings() { selectedCamera={selectedCamera} setUnsavedChanges={setUnsavedChanges} selectedZoneMask={filterZoneMask} + onSectionStatusChange={handleSectionStatusChange} /> ); })()} @@ -779,9 +915,9 @@ export default function Settings() { } }} > -
- {t("menu." + filteredItems[0].key)} -
+ {renderMenuItemLabel( + filteredItems[0].key as SettingsType, + )} @@ -819,8 +955,10 @@ export default function Settings() { } }} > -
- {t("menu." + item.key)} +
+ {renderMenuItemLabel( + item.key as SettingsType, + )}
@@ -844,6 +982,7 @@ export default function Settings() { selectedCamera={selectedCamera} setUnsavedChanges={setUnsavedChanges} selectedZoneMask={filterZoneMask} + onSectionStatusChange={handleSectionStatusChange} /> ); })()} diff --git a/web/src/views/settings/SingleSectionPage.tsx b/web/src/views/settings/SingleSectionPage.tsx index 6bb494115..97184e634 100644 --- a/web/src/views/settings/SingleSectionPage.tsx +++ b/web/src/views/settings/SingleSectionPage.tsx @@ -1,13 +1,25 @@ +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import Heading from "@/components/ui/heading"; import type { SectionConfig } from "@/components/config-form/sections"; import { ConfigSectionTemplate } from "@/components/config-form/sections"; import type { PolygonType } from "@/types/canvas"; +import { Badge } from "@/components/ui/badge"; export type SettingsPageProps = { selectedCamera?: string; setUnsavedChanges?: React.Dispatch>; selectedZoneMask?: PolygonType[]; + onSectionStatusChange?: ( + sectionKey: string, + level: "global" | "camera", + status: SectionStatus, + ) => void; +}; + +export type SectionStatus = { + hasChanges: boolean; + isOverridden: boolean; }; export type SingleSectionPageOptions = { @@ -29,6 +41,7 @@ export function SingleSectionPage({ showOverrideIndicator = true, selectedCamera, setUnsavedChanges, + onSectionStatusChange, }: SingleSectionPageProps) { const sectionNamespace = level === "camera" ? "config/cameras" : "config/global"; @@ -37,6 +50,14 @@ export function SingleSectionPage({ "views/settings", "common", ]); + const [sectionStatus, setSectionStatus] = useState({ + hasChanges: false, + isOverridden: false, + }); + + useEffect(() => { + onSectionStatusChange?.(sectionKey, level, sectionStatus); + }, [onSectionStatusChange, sectionKey, level, sectionStatus]); if (level === "camera" && !selectedCamera) { return ( @@ -48,10 +69,11 @@ export function SingleSectionPage({ return (
-
+
{t(`${sectionKey}.label`, { ns: sectionNamespace })} + {i18n.exists(`${sectionKey}.description`, { ns: sectionNamespace, }) && ( @@ -59,6 +81,20 @@ export function SingleSectionPage({ {t(`${sectionKey}.description`, { ns: sectionNamespace })}

)} +
+ {level === "camera" && + showOverrideIndicator && + sectionStatus.isOverridden && ( + + {t("overridden", { ns: "common", defaultValue: "Overridden" })} + + )} + {sectionStatus.hasChanges && ( + + {t("modified", { ns: "common", defaultValue: "Modified" })} + + )} +
);