import { useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { LuExternalLink, LuFilter } from "react-icons/lu"; import { toast } from "sonner"; import axios from "axios"; import useSWR from "swr"; import { useSWRConfig } from "swr"; import { cn } from "@/lib/utils"; import { useRestart } from "@/api/ws"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import Heading from "@/components/ui/heading"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Toaster } from "@/components/ui/sonner"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, } from "@/components/ui/select"; import type { FrigateConfig } from "@/types/frigateConfig"; import type { SectionStatus, SettingsPageProps, } from "@/views/settings/SingleSectionPage"; import type { ConfigSectionData } from "@/types/configForm"; import { SettingsGroupCard, SplitCardRow, } from "@/components/card/SettingsGroupCard"; import { ConfigSectionTemplate } from "@/components/config-form/sections"; import { ConfigMessageBanner } from "@/components/config-form/ConfigMessageBanner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { buildHiddenFieldContext, getSectionConfig, resolveHiddenFieldEntries, sanitizeSectionData, } from "@/utils/configUtil"; type ModelTab = "plus" | "custom"; type PageState = { detectors: ConfigSectionData; modelTab: ModelTab; plusModelId: string | undefined; customModel: ConfigSectionData; }; type FrigatePlusModel = { id: string; type: string; name: string; isBaseModel: boolean; supportedDetectors: string[]; trainDate: string; baseModel: string; width: number; height: number; }; const TYPE_MODEL_DEFAULTS: Record = { cpu: { path: "/cpu_model.tflite", labelmap_path: "/labelmap.txt", width: 320, height: 320, input_tensor: "nhwc", input_pixel_format: "rgb", input_dtype: "int", model_type: "ssd", }, edgetpu: { path: "/edgetpu_model.tflite", labelmap_path: "/labelmap.txt", width: 320, height: 320, input_tensor: "nhwc", input_pixel_format: "rgb", input_dtype: "int", model_type: "ssd", }, openvino: { path: "/openvino-model/ssdlite_mobilenet_v2.xml", labelmap_path: "/openvino-model/coco_91cl_bkgr.txt", width: 300, height: 300, input_tensor: "nhwc", input_pixel_format: "bgr", input_dtype: "int", model_type: "ssd", }, }; const STATUS_BAR_KEY = "detectors_and_model"; const EMPTY_PENDING: Record = {}; const deriveInitialState = (config: FrigateConfig): PageState => { const plusModelId = config.model?.plus?.id; const modelPath = config.model?.path; const plusEnabled = Boolean(config.plus?.enabled); // The reliable signal that a Plus model is currently active is the // `model.plus.id` metadata let modelTab: ModelTab; if (plusModelId) { modelTab = "plus"; } else if (typeof modelPath === "string" && modelPath.length > 0) { modelTab = "custom"; } else if (plusEnabled) { modelTab = "plus"; } else { modelTab = "custom"; } // Fallback: if Plus is not enabled, prefer Custom regardless of saved state if (!plusEnabled && modelTab === "plus") { modelTab = "custom"; } const { plus: _plus, ...modelWithoutPlus } = (config.model ?? {}) as Record< string, unknown >; // If a Plus model is active, the resolved `model.path` is auto-derived from // `plus.id` — drop it so the Custom tab starts clean and doesn't silently // re-save the same Plus model when the user thinks they switched modes. if (plusModelId) { delete modelWithoutPlus.path; } return { detectors: (config.detectors ?? {}) as ConfigSectionData, modelTab, plusModelId: plusModelId ?? undefined, customModel: modelWithoutPlus as ConfigSectionData, }; }; export default function DetectorsAndModelSettingsView({ setUnsavedChanges, pendingDataBySection, onPendingDataChange, onSectionStatusChange, isSavingAll, onSectionSavingChange, }: SettingsPageProps) { const { t } = useTranslation(["views/settings", "common"]); const { getLocaleDocUrl } = useDocDomain(); const { data: config } = useSWR("config"); const { mutate: globalMutate } = useSWRConfig(); const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; // track the saved config const snapshot = useMemo( () => (config ? deriveInitialState(config) : null), [config], ); const [state, setState] = useState(null); const [isSaving, setIsSaving] = useState(false); const [resetKey, setResetKey] = useState(0); const [restartDialogOpen, setRestartDialogOpen] = useState(false); const { send: sendRestart } = useRestart(); const childPending = pendingDataBySection ?? EMPTY_PENDING; const [detectorStatus, setDetectorStatus] = useState({ hasChanges: false, isOverridden: false, hasValidationErrors: false, }); const [modelStatus, setModelStatus] = useState({ hasChanges: false, isOverridden: false, hasValidationErrors: false, }); const [showBaseModels, setShowBaseModels] = useState(true); const [showFineTunedModels, setShowFineTunedModels] = useState(true); const plusEnabled = Boolean(config?.plus?.enabled); const { data: availableModels = {}, isLoading: isLoadingModels } = useSWR< Record >(plusEnabled ? "/plus/models" : null, { fallbackData: {}, fetcher: async (url) => { const res = await axios.get(url, { withCredentials: true }); return res.data.reduce( (obj: Record, model: FrigatePlusModel) => { obj[model.id] = model; return obj; }, {}, ); }, }); const filteredModelEntries = useMemo( () => Object.entries(availableModels || {}).filter(([, model]) => model.isBaseModel ? showBaseModels : showFineTunedModels, ), [availableModels, showBaseModels, showFineTunedModels], ); const isFilterActive = !showBaseModels || !showFineTunedModels; const detectorHiddenFields = useMemo( () => resolveHiddenFieldEntries( getSectionConfig("detectors", "global").hiddenFields, buildHiddenFieldContext(config, "global"), ), [config], ); const modelHiddenFields = useMemo( () => resolveHiddenFieldEntries( getSectionConfig("model", "global").hiddenFields, buildHiddenFieldContext(config, "global"), ), [config], ); const liveDetectors = useMemo( () => childPending["detectors"] ?? snapshot?.detectors, [childPending, snapshot], ); const liveCustomModel = useMemo( () => childPending["model"] ?? snapshot?.customModel, [childPending, snapshot], ); const currentDetectorType = useMemo(() => { const values = Object.values(liveDetectors ?? {}); if (values.length === 0) return undefined; const first = values[0] as { type?: string } | undefined; return first?.type; }, [liveDetectors]); // fill in defaults when detector type changes const prevDetectorTypeRef = useRef(undefined); useEffect(() => { const newType = currentDetectorType; const prevType = prevDetectorTypeRef.current; prevDetectorTypeRef.current = newType; if (prevType === undefined || prevType === newType) return; if (!newType || !(newType in TYPE_MODEL_DEFAULTS)) return; const defaults = TYPE_MODEL_DEFAULTS[newType]; onPendingDataChange?.("model", undefined, defaults); if (newType === "openvino") { const detectorsCurrent = (childPending.detectors ?? state?.detectors ?? {}) as { [key: string]: { device?: string }; }; const entries = Object.entries(detectorsCurrent); if (entries.length > 0) { const [firstKey, firstValue] = entries[0]; if (!firstValue?.device) { onPendingDataChange?.("detectors", undefined, { ...detectorsCurrent, [firstKey]: { ...firstValue, device: "CPU" }, } as ConfigSectionData); } } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentDetectorType]); const isModelCompatible = useCallback( (model: FrigatePlusModel) => currentDetectorType ? model.supportedDetectors.includes(currentDetectorType) : true, [currentDetectorType], ); const selectedPlusModel = state?.plusModelId ? availableModels?.[state.plusModelId] : undefined; const plusMismatch = state?.modelTab === "plus" && selectedPlusModel !== undefined && currentDetectorType !== undefined && !isModelCompatible(selectedPlusModel); const plusModelMissing = state?.modelTab === "plus" && !state?.plusModelId; const handleDetectorStatusChange = useCallback( (status: SectionStatus) => { setDetectorStatus(status); onSectionStatusChange?.("detectors", "global", status); }, [onSectionStatusChange], ); // BaseSection drives `modelStatus` only when the Custom tab is mounted const handleModelStatusChange = useCallback( (status: SectionStatus) => setModelStatus(status), [], ); // report the *combined* model-section status to the parent useEffect(() => { if (!state || !snapshot) return; const tabChanged = state.modelTab !== snapshot.modelTab; const plusIdChanged = state.modelTab === "plus" && state.plusModelId !== snapshot.plusModelId; const pageLevelDirty = tabChanged || plusIdChanged; onSectionStatusChange?.("model", "global", { hasChanges: modelStatus.hasChanges || pageLevelDirty, isOverridden: modelStatus.isOverridden, overrideSource: modelStatus.overrideSource, hasValidationErrors: modelStatus.hasValidationErrors, }); }, [state, snapshot, modelStatus, onSectionStatusChange]); // Tab toggle and Plus-model selection are page-local UI, but Save All and the // sidebar dot live on `pendingDataBySection["model"]` and section status from // the parent. These handlers mirror Plus-tab changes into both so a Plus-only // edit (no custom-form typing) is still dirty and survives navigation. const handleModelTabChange = useCallback( (newTab: ModelTab) => { setState((prev) => (prev ? { ...prev, modelTab: newTab } : prev)); if (!snapshot) return; if (newTab === "plus") { if (state?.plusModelId) { onPendingDataChange?.("model", undefined, { path: `plus://${state.plusModelId}`, } as ConfigSectionData); } else { // No Plus model selected — clear any stale pending so the save // action is correctly disabled until the user picks one. onPendingDataChange?.("model", undefined, null); } } else { // Switching to Custom: if pending["model"] still holds a plus path // from a previous Plus selection, swap it for the snapshot's custom // model so Save All writes the correct payload. Don't overwrite // genuine custom-form edits the user typed earlier. const currentPath = ( pendingDataBySection?.["model"] as { path?: string } | undefined )?.path; if ( typeof currentPath === "string" && currentPath.startsWith("plus://") ) { onPendingDataChange?.( "model", undefined, snapshot.customModel as ConfigSectionData, ); } } }, [state?.plusModelId, snapshot, pendingDataBySection, onPendingDataChange], ); const handlePlusModelIdChange = useCallback( (newId: string) => { setState((prev) => (prev ? { ...prev, plusModelId: newId } : prev)); onPendingDataChange?.("model", undefined, { path: `plus://${newId}`, } as ConfigSectionData); }, [onPendingDataChange], ); useEffect(() => { if (!config || state !== null) return; const initial = deriveInitialState(config); // Restore Plus-tab UI state from any prior pending edits the user made // before navigating away. `pendingDataBySection["model"]` is the source of // truth for Save All; infer modelTab/plusModelId from it so the UI lines up. const pendingModel = pendingDataBySection?.["model"] as | { path?: string } | undefined; const pendingPath = pendingModel?.path; if (typeof pendingPath === "string" && pendingPath.startsWith("plus://")) { setState({ ...initial, modelTab: "plus", plusModelId: pendingPath.slice("plus://".length) || undefined, }); } else if (pendingModel && initial.modelTab === "plus") { // There's a pending custom-model edit while the saved tab was Plus — // means the user already switched to Custom before navigating away. setState({ ...initial, modelTab: "custom" }); } else { setState(initial); } }, [config, state, pendingDataBySection]); const isDirty = useMemo(() => { if (!state || !snapshot) return false; if (state.modelTab !== snapshot.modelTab) return true; if (state.plusModelId !== snapshot.plusModelId) return true; if ("detectors" in childPending) return true; if ("model" in childPending) return true; return false; }, [state, snapshot, childPending]); useEffect(() => { if (isDirty) { addMessage( STATUS_BAR_KEY, t("detectorsAndModel.unsavedChanges"), undefined, STATUS_BAR_KEY, ); } else { removeMessage(STATUS_BAR_KEY, STATUS_BAR_KEY); } setUnsavedChanges?.(isDirty); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDirty]); useEffect(() => { document.title = t("documentTitle.detectorsAndModel"); }, [t]); const onSave = useCallback(async () => { if (!state || !snapshot) return; const tabChanged = state.modelTab !== snapshot.modelTab; // Strip computed/merged fields that the backend populates in /config // responses but doesn't accept back on /config/set. const sanitizedDetectors = sanitizeSectionData( liveDetectors ?? {}, detectorHiddenFields, ); const sanitizedCustomModel = sanitizeSectionData( liveCustomModel ?? {}, modelHiddenFields, ); const modelPayload = state.modelTab === "plus" ? { path: `plus://${state.plusModelId}` } : sanitizedCustomModel; const detectorKeysChanged = JSON.stringify(Object.keys(liveDetectors ?? {}).sort()) !== JSON.stringify(Object.keys(snapshot.detectors).sort()); setIsSaving(true); onSectionSavingChange?.(true); let preCleared = false; try { // Pre-clear both `detectors` and `model` together when renaming if (tabChanged || detectorKeysChanged) { try { await axios.put("config/set", { requires_restart: 0, config_data: { detectors: null, model: null }, }); preCleared = true; } catch { // best-effort cleanup } } await axios.put("config/set", { requires_restart: 0, config_data: { detectors: sanitizedDetectors, model: modelPayload, }, }); await globalMutate("config"); await globalMutate("config/raw_paths"); // `snapshot` is derived from `config` via useMemo, so the awaited mutate // above has already refreshed it. Just clear the pending entries — that // resets isDirty since state should now match snapshot. onPendingDataChange?.("detectors", undefined, null); onPendingDataChange?.("model", undefined, null); setResetKey((k) => k + 1); addMessage( "detectors_and_model_restart", t("detectorsAndModel.restartRequired"), undefined, "detectors_and_model_restart", ); toast.success(t("detectorsAndModel.toast.saveSuccess"), { position: "top-center", duration: 10000, action: ( ), }); } catch (error) { const err = error as { response?: { data?: { message?: string; detail?: string } }; }; const message = err.response?.data?.message || err.response?.data?.detail || t("detectorsAndModel.toast.saveError"); toast.error(message, { position: "top-center" }); if (preCleared) { const restoreModel = snapshot.modelTab === "plus" && snapshot.plusModelId ? { path: `plus://${snapshot.plusModelId}` } : sanitizeSectionData(snapshot.customModel, modelHiddenFields); try { await axios.put("config/set", { requires_restart: 0, config_data: { detectors: sanitizeSectionData( snapshot.detectors, detectorHiddenFields, ), model: restoreModel, }, }); } catch { // best-effort } } // Re-sync the config cache to reflect whatever state the backend // landed on after the failure (and any restore attempt). await globalMutate("config"); } finally { setIsSaving(false); onSectionSavingChange?.(false); } }, [ state, snapshot, liveDetectors, liveCustomModel, detectorHiddenFields, modelHiddenFields, globalMutate, onSectionSavingChange, addMessage, onPendingDataChange, t, ]); const onUndo = useCallback(() => { if (snapshot) { setState(snapshot); onPendingDataChange?.("detectors", undefined, null); onPendingDataChange?.("model", undefined, null); // Force the embedded forms to re-mount so their internal dirty/baseline // state is rebuilt from the current config — clearing pending alone // doesn't reset BaseSection's internal tracking. setResetKey((k) => k + 1); } }, [snapshot, onPendingDataChange]); if (!config || !state) { return ; } const saveDisabled = !isDirty || isSaving || isSavingAll || detectorStatus.hasValidationErrors || (state.modelTab === "custom" && modelStatus.hasValidationErrors) || plusMismatch || plusModelMissing; return (
{t("detectorsAndModel.title")}
{t("detectorsAndModel.description")}
{t("readTheDocumentation", { ns: "common" })}
{isDirty && ( {t("button.modified", { ns: "common", defaultValue: "Modified" })} )}
{plusMismatch && selectedPlusModel && ( true, values: { model: selectedPlusModel.name, required: selectedPlusModel.supportedDetectors.join(", "), }, }, ]} /> )} {plusEnabled ? ( handleModelTabChange(value as ModelTab) } > {t("detectorsAndModel.tabs.plus")} {t("detectorsAndModel.tabs.custom")} frigatePlus.modelInfo.modelSelect } content={
{t("frigatePlus.modelInfo.filter.ariaLabel")}
} />
) : ( )}
{isDirty && ( {t("unsavedChanges", { ns: "views/settings" })} )}
{isDirty && ( )}
setRestartDialogOpen(false)} onRestart={() => sendRestart("restart")} />
); }