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 { sanitizeSectionData } from "@/utils/configUtil"; const DETECTOR_HIDDEN_FIELDS = [ "*.model.labelmap", "*.model.attributes_map", "*.model", "*.model_path", ]; const MODEL_HIDDEN_FIELDS = [ "labelmap", "attributes_map", "colormap", "all_attributes", "non_logo_attributes", ]; 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 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, }: SettingsPageProps) { const { t } = useTranslation(["views/settings", "common"]); const { getLocaleDocUrl } = useDocDomain(); const { data: config } = useSWR("config"); const { mutate: globalMutate } = useSWRConfig(); const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; const [snapshot, setSnapshot] = useState(null); 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, setChildPending] = useState< Record >({}); 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; // The "live" detector/model data lives in `childPending` (driven by the // embedded forms) — derive on demand instead of mirroring it into state via // a draining useEffect 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]; setChildPending((prev) => { const next: Record = { ...prev, model: defaults, }; if (newType === "openvino") { const detectorsCurrent = (prev.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) { next.detectors = { ...detectorsCurrent, [firstKey]: { ...firstValue, device: "CPU" }, } as ConfigSectionData; } } } return next; }); // 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 handleChildPendingChange = useCallback( ( sectionKey: string, _cameraName: string | undefined, data: ConfigSectionData | null, ) => { setChildPending((prev) => { if (data === null) { if (!(sectionKey in prev)) return prev; const { [sectionKey]: _drop, ...rest } = prev; return rest; } return { ...prev, [sectionKey]: data }; }); }, [], ); const handleDetectorStatusChange = useCallback( (status: SectionStatus) => setDetectorStatus(status), [], ); const handleModelStatusChange = useCallback( (status: SectionStatus) => setModelStatus(status), [], ); useEffect(() => { if (!config || snapshot !== null) return; const initial = deriveInitialState(config); setSnapshot(initial); setState(initial); }, [config, snapshot]); 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 ?? {}, DETECTOR_HIDDEN_FIELDS, ); const sanitizedCustomModel = sanitizeSectionData( liveCustomModel ?? {}, MODEL_HIDDEN_FIELDS, ); 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); 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"); // Re-derive snapshot from the freshly saved data so isDirty resets. setSnapshot({ modelTab: state.modelTab, plusModelId: state.plusModelId, detectors: liveDetectors ?? snapshot.detectors, customModel: liveCustomModel ?? snapshot.customModel, }); setChildPending({}); 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", 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, MODEL_HIDDEN_FIELDS); try { await axios.put("config/set", { requires_restart: 0, config_data: { detectors: sanitizeSectionData( snapshot.detectors, DETECTOR_HIDDEN_FIELDS, ), 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); } }, [ state, snapshot, liveDetectors, liveCustomModel, globalMutate, addMessage, t, ]); const onUndo = useCallback(() => { if (snapshot) { setState(snapshot); setChildPending({}); // Force the embedded forms to re-mount so their internal dirty/baseline // state is rebuilt from the current config — clearing childPending alone // doesn't reset BaseSection's internal tracking. setResetKey((k) => k + 1); } }, [snapshot]); if (!config || !state) { return ; } const saveDisabled = !isDirty || isSaving || 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 ? ( setState((prev) => prev ? { ...prev, modelTab: value as ModelTab } : prev, ) } > {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")} />
); }