From 8203e39b7f3408e348e84d717b98bf6f9b40f6d9 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:10:23 -0500 Subject: [PATCH] add go2rtc settings section to the save all flow (#23501) --- web/src/pages/Settings.tsx | 106 ++++++- .../settings/Go2RtcStreamsSettingsView.tsx | 258 +++++++++++------- 2 files changed, 259 insertions(+), 105 deletions(-) diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index f49c657b40..6f445f0c47 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -34,6 +34,8 @@ import { isMobile } from "react-device-detect"; import { FaVideo } from "react-icons/fa"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import type { ConfigSectionData, JsonObject } from "@/types/configForm"; +import isEqual from "lodash/isEqual"; +import { maskCredentials } from "@/utils/credentialMask"; import useSWR from "swr"; import FilterSwitch from "@/components/filter/FilterSwitch"; import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; @@ -660,6 +662,11 @@ export default function Settings() { const isAdmin = useIsAdmin(); + // for unmasked go2rtc stream sources + const { data: rawPaths } = useSWR<{ + go2rtc: { streams: Record }; + }>(isAdmin ? "config/raw_paths" : null); + const visibleSettingsViews = !isAdmin ? ALLOWED_VIEWS_FOR_VIEWER : allSettingsViews; @@ -788,6 +795,40 @@ export default function Settings() { }, ); + // go2rtc streams aren't schema-backed, so build their preview items directly + if ("go2rtc_streams" in pendingDataBySection) { + const live = + (pendingDataBySection["go2rtc_streams"] as Record) ?? + {}; + const saved: Record = {}; + for (const [name, urls] of Object.entries( + rawPaths?.go2rtc?.streams ?? {}, + )) { + saved[name] = Array.isArray(urls) ? urls : [urls]; + } + + // Added or changed streams + for (const [name, urls] of Object.entries(live)) { + if (name in saved && isEqual(urls, saved[name])) continue; + const masked = urls.map((url) => maskCredentials(url)); + items.push({ + scope: "global", + fieldPath: `go2rtc.streams.${name}`, + value: masked.length === 1 ? masked[0] : masked, + }); + } + + // Deleted streams (present in saved config, absent from pending) + for (const name of Object.keys(saved)) { + if (name in live) continue; + items.push({ + scope: "global", + fieldPath: `go2rtc.streams.${name}`, + value: "", + }); + } + } + return items.sort((left, right) => { const scopeCompare = left.scope.localeCompare(right.scope); if (scopeCompare !== 0) return scopeCompare; @@ -797,7 +838,13 @@ export default function Settings() { if (cameraCompare !== 0) return cameraCompare; return left.fieldPath.localeCompare(right.fieldPath); }); - }, [config, fullSchema, pendingDataBySection, profileFriendlyNames]); + }, [ + config, + fullSchema, + pendingDataBySection, + profileFriendlyNames, + rawPaths, + ]); // Map a pendingDataKey to SettingsType menu key for clearing section status const pendingKeyToMenuKey = useCallback( @@ -869,10 +916,7 @@ export default function Settings() { // after `mutate("config")` resolves const keysToClear: string[] = []; - // `detectors` and `model` are owned by DetectorsAndModelSettingsView, - // which saves them atomically (single combined PUT with a pre-clear when - // detector keys change or the Plus/Custom tab flips). Doing the same here - // keeps Save All consistent with the page's own Save button + // `detectors` and `model` are owned by DetectorsAndModelSettingsView const hasPendingDetectors = "detectors" in pendingDataBySection; const hasPendingModel = "model" in pendingDataBySection; if (hasPendingDetectors || hasPendingModel) { @@ -975,8 +1019,58 @@ export default function Settings() { } } + // go2rtc streams are owned by Go2RtcStreamsSettingsView + if ("go2rtc_streams" in pendingDataBySection) { + try { + const liveStreams = + (pendingDataBySection["go2rtc_streams"] as Record< + string, + string[] + >) ?? {}; + const streamsPayload: Record = { + ...liveStreams, + }; + const deletedStreamNames = Object.keys( + config.go2rtc?.streams ?? {}, + ).filter((name) => !(name in liveStreams)); + for (const deleted of deletedStreamNames) { + streamsPayload[deleted] = ""; + } + + await axios.put("config/set", { + requires_restart: 0, + config_data: { go2rtc: { streams: streamsPayload } }, + }); + + // Update the running go2rtc instance to match + const go2rtcUpdates: Promise[] = []; + for (const [streamName, urls] of Object.entries(liveStreams)) { + if (urls[0]) { + go2rtcUpdates.push( + axios.put( + `go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`, + ), + ); + } + } + for (const deleted of deletedStreamNames) { + go2rtcUpdates.push(axios.delete(`go2rtc/streams/${deleted}`)); + } + await Promise.allSettled(go2rtcUpdates); + + keysToClear.push("go2rtc_streams"); + savedKeys.push("go2rtc_streams"); + successCount++; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Save All – error saving go2rtc streams", error); + failCount++; + } + } + const pendingKeys = Object.keys(pendingDataBySection).filter( - (key) => key !== "detectors" && key !== "model", + (key) => + key !== "detectors" && key !== "model" && key !== "go2rtc_streams", ); for (const key of pendingKeys) { diff --git a/web/src/views/settings/Go2RtcStreamsSettingsView.tsx b/web/src/views/settings/Go2RtcStreamsSettingsView.tsx index 0dabbebc05..49d3539713 100644 --- a/web/src/views/settings/Go2RtcStreamsSettingsView.tsx +++ b/web/src/views/settings/Go2RtcStreamsSettingsView.tsx @@ -58,8 +58,13 @@ import { DialogTitle, } from "@/components/ui/dialog"; import ActivityIndicator from "@/components/indicators/activity-indicator"; +import SaveAllPreviewPopover, { + type SaveAllPreviewItem, +} from "@/components/overlay/detail/SaveAllPreviewPopover"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { FrigateConfig } from "@/types/frigateConfig"; +import type { SettingsPageProps } from "@/views/settings/SingleSectionPage"; +import type { ConfigSectionData } from "@/types/configForm"; import { cn } from "@/lib/utils"; import { isMaskedPath, @@ -85,18 +90,8 @@ type RawPathsResponse = { go2rtc: { streams: Record }; }; -type Go2RtcStreamsSettingsViewProps = { - setUnsavedChanges: React.Dispatch>; - onSectionStatusChange?: ( - sectionKey: string, - level: "global" | "camera", - status: { - hasChanges: boolean; - isOverridden: boolean; - hasValidationErrors: boolean; - }, - ) => void; -}; +const SECTION_KEY = "go2rtc_streams"; +const EMPTY_PENDING: Record = {}; const STREAM_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; @@ -114,7 +109,11 @@ function normalizeStreams( export default function Go2RtcStreamsSettingsView({ setUnsavedChanges, onSectionStatusChange, -}: Go2RtcStreamsSettingsViewProps) { + pendingDataBySection, + onPendingDataChange, + isSavingAll, + onSectionSavingChange, +}: SettingsPageProps) { const { t } = useTranslation(["views/settings", "common"]); const { getLocaleDocUrl } = useDocDomain(); const { data: config, mutate: updateConfig } = @@ -122,13 +121,6 @@ export default function Go2RtcStreamsSettingsView({ const { data: rawPaths, mutate: updateRawPaths } = useSWR("config/raw_paths"); - const [editedStreams, setEditedStreams] = useState>( - {}, - ); - const [serverStreams, setServerStreams] = useState>( - {}, - ); - const [initialized, setInitialized] = useState(false); const [isLoading, setIsLoading] = useState(false); const [credentialVisibility, setCredentialVisibility] = useState< Record @@ -138,34 +130,51 @@ export default function Go2RtcStreamsSettingsView({ const [addStreamDialogOpen, setAddStreamDialogOpen] = useState(false); const [newlyAdded, setNewlyAdded] = useState>(new Set()); - // Initialize from config — wait for both config and rawPaths to avoid - // a mismatch when rawPaths arrives after config with different data - useEffect(() => { - if (!config || !rawPaths) return; + const childPending = pendingDataBySection ?? EMPTY_PENDING; - // Always use rawPaths for go2rtc streams — the /config endpoint masks - // credentials, so using config.go2rtc.streams would save masked values - const normalized = normalizeStreams(rawPaths.go2rtc?.streams); + // Saved/server state. Always read from rawPaths + const serverStreams = useMemo>( + () => normalizeStreams(rawPaths?.go2rtc?.streams), + [rawPaths], + ); - setServerStreams(normalized); - if (!initialized) { - setEditedStreams(normalized); - setInitialized(true); - } - }, [config, rawPaths, initialized]); + // Pending edits live in the parent's store so they survive navigation; fall back to saved state + const liveStreams = useMemo>( + () => + (childPending[SECTION_KEY] as Record | undefined) ?? + serverStreams, + [childPending, serverStreams], + ); + + // Persist edits to the parent store, clearing the entry when an edit returns + // the section to its saved state so Save All and the sidebar dot reset cleanly. + const commitStreams = useCallback( + (next: Record) => { + if (isEqual(next, serverStreams)) { + onPendingDataChange?.(SECTION_KEY, undefined, null); + } else { + onPendingDataChange?.( + SECTION_KEY, + undefined, + next as ConfigSectionData, + ); + } + }, + [serverStreams, onPendingDataChange], + ); // Track unsaved changes const hasChanges = useMemo( - () => initialized && !isEqual(editedStreams, serverStreams), - [editedStreams, serverStreams, initialized], + () => !isEqual(liveStreams, serverStreams), + [liveStreams, serverStreams], ); useEffect(() => { - setUnsavedChanges(hasChanges); + setUnsavedChanges?.(hasChanges); }, [hasChanges, setUnsavedChanges]); const hasValidationErrors = useMemo(() => { - const names = Object.keys(editedStreams); + const names = Object.keys(liveStreams); const seenNames = new Set(); for (const name of names) { @@ -173,13 +182,43 @@ export default function Go2RtcStreamsSettingsView({ if (seenNames.has(name)) return true; seenNames.add(name); - const urls = editedStreams[name]; + const urls = liveStreams[name]; if (!urls || urls.length === 0 || urls.every((u) => !u.trim())) return true; } return false; - }, [editedStreams]); + }, [liveStreams]); + + // Pending changes for this section's Save All preview popover. Diff the + // pending streams against the saved state and mask credentials for display. + const sectionPreviewItems = useMemo(() => { + if (!hasChanges) return []; + const items: SaveAllPreviewItem[] = []; + + // Added or changed streams + for (const [name, urls] of Object.entries(liveStreams)) { + if (name in serverStreams && isEqual(urls, serverStreams[name])) continue; + const masked = urls.map((url) => maskCredentials(url)); + items.push({ + scope: "global", + fieldPath: `go2rtc.streams.${name}`, + value: masked.length === 1 ? masked[0] : masked, + }); + } + + // Deleted streams (present in saved config, absent from pending) + for (const name of Object.keys(serverStreams)) { + if (name in liveStreams) continue; + items.push({ + scope: "global", + fieldPath: `go2rtc.streams.${name}`, + value: "", + }); + } + + return items; + }, [hasChanges, liveStreams, serverStreams]); // Report status to parent for sidebar red dot useEffect(() => { @@ -193,13 +232,14 @@ export default function Go2RtcStreamsSettingsView({ // Save handler const saveToConfig = useCallback(async () => { setIsLoading(true); + onSectionSavingChange?.(true); try { const streamsPayload: Record = { - ...editedStreams, + ...liveStreams, }; const deletedStreamNames = Object.keys(serverStreams).filter( - (name) => !(name in editedStreams), + (name) => !(name in liveStreams), ); for (const deleted of deletedStreamNames) { streamsPayload[deleted] = ""; @@ -212,7 +252,7 @@ export default function Go2RtcStreamsSettingsView({ // Update running go2rtc instance const go2rtcUpdates: Promise[] = []; - for (const [streamName, urls] of Object.entries(editedStreams)) { + for (const [streamName, urls] of Object.entries(liveStreams)) { if (urls[0]) { go2rtcUpdates.push( axios.put( @@ -233,9 +273,9 @@ export default function Go2RtcStreamsSettingsView({ }), ); - setServerStreams(editedStreams); - updateConfig(); - updateRawPaths(); + await updateConfig(); + await updateRawPaths(); + onPendingDataChange?.(SECTION_KEY, undefined, null); } catch { toast.error( t("toast.error", { @@ -245,74 +285,86 @@ export default function Go2RtcStreamsSettingsView({ ); } finally { setIsLoading(false); + onSectionSavingChange?.(false); } - }, [editedStreams, serverStreams, t, updateConfig, updateRawPaths]); + }, [ + liveStreams, + serverStreams, + t, + updateConfig, + updateRawPaths, + onPendingDataChange, + onSectionSavingChange, + ]); // Reset handler const onReset = useCallback(() => { - setEditedStreams(serverStreams); + onPendingDataChange?.(SECTION_KEY, undefined, null); setCredentialVisibility({}); - }, [serverStreams]); + }, [onPendingDataChange]); // Stream CRUD operations - const addStream = useCallback((name: string) => { - setEditedStreams((prev) => ({ ...prev, [name]: [""] })); - setNewlyAdded((prev) => new Set(prev).add(name)); - setAddStreamDialogOpen(false); - }, []); + const addStream = useCallback( + (name: string) => { + commitStreams({ ...liveStreams, [name]: [""] }); + setNewlyAdded((prev) => new Set(prev).add(name)); + setAddStreamDialogOpen(false); + }, + [liveStreams, commitStreams], + ); - const deleteStream = useCallback((streamName: string) => { - setEditedStreams((prev) => { - const { [streamName]: _, ...rest } = prev; - return rest; - }); - setDeleteDialog(null); - }, []); + const deleteStream = useCallback( + (streamName: string) => { + const { [streamName]: _removed, ...rest } = liveStreams; + commitStreams(rest); + setDeleteDialog(null); + }, + [liveStreams, commitStreams], + ); - const renameStream = useCallback((oldName: string, newName: string) => { - if (oldName === newName || !newName.trim()) return; + const renameStream = useCallback( + (oldName: string, newName: string) => { + if (oldName === newName || !newName.trim()) return; + if (!(oldName in liveStreams)) return; - setEditedStreams((prev) => { - const urls = prev[oldName]; - if (!urls) return prev; - - const entries = Object.entries(prev); const result: Record = {}; - for (const [key, value] of entries) { - if (key === oldName) { - result[newName] = value; - } else { - result[key] = value; - } + for (const [key, value] of Object.entries(liveStreams)) { + result[key === oldName ? newName : key] = value; } - return result; - }); - }, []); + commitStreams(result); + }, + [liveStreams, commitStreams], + ); const updateUrl = useCallback( (streamName: string, urlIndex: number, newUrl: string) => { - setEditedStreams((prev) => { - const urls = [...(prev[streamName] || [])]; - urls[urlIndex] = newUrl; - return { ...prev, [streamName]: urls }; - }); + const urls = [...(liveStreams[streamName] || [])]; + urls[urlIndex] = newUrl; + commitStreams({ ...liveStreams, [streamName]: urls }); }, - [], + [liveStreams, commitStreams], ); - const addUrl = useCallback((streamName: string) => { - setEditedStreams((prev) => { - const urls = [...(prev[streamName] || []), ""]; - return { ...prev, [streamName]: urls }; - }); - }, []); + const addUrl = useCallback( + (streamName: string) => { + const urls = [...(liveStreams[streamName] || []), ""]; + commitStreams({ ...liveStreams, [streamName]: urls }); + }, + [liveStreams, commitStreams], + ); - const removeUrl = useCallback((streamName: string, urlIndex: number) => { - setEditedStreams((prev) => { - const urls = (prev[streamName] || []).filter((_, i) => i !== urlIndex); - return { ...prev, [streamName]: urls.length > 0 ? urls : [""] }; - }); - }, []); + const removeUrl = useCallback( + (streamName: string, urlIndex: number) => { + const urls = (liveStreams[streamName] || []).filter( + (_, i) => i !== urlIndex, + ); + commitStreams({ + ...liveStreams, + [streamName]: urls.length > 0 ? urls : [""], + }); + }, + [liveStreams, commitStreams], + ); const toggleCredentialVisibility = useCallback((key: string) => { setCredentialVisibility((prev) => ({ ...prev, [key]: !prev[key] })); @@ -320,7 +372,7 @@ export default function Go2RtcStreamsSettingsView({ if (!config) return null; - const streamEntries = Object.entries(editedStreams); + const streamEntries = Object.entries(liveStreams); return (
@@ -391,6 +443,12 @@ export default function Go2RtcStreamsSettingsView({ {t("unsavedChanges")} +
)}
@@ -398,7 +456,7 @@ export default function Go2RtcStreamsSettingsView({