import { useCallback, useContext, useEffect, useMemo, 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 isEqual from "lodash/isEqual"; 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 } from "@/components/card/SettingsGroupCard"; import { ConfigSectionTemplate } from "@/components/config-form/sections"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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 STATUS_BAR_KEY = "detectors_and_model"; const deriveInitialState = (config: FrigateConfig): PageState => { const modelPath = config.model?.path; const plusEnabled = Boolean(config.plus?.enabled); let modelTab: ModelTab; if (typeof modelPath === "string" && modelPath.startsWith("plus://")) { 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 path if (!plusEnabled && modelTab === "plus") { modelTab = "custom"; } const plusModelId = config.model?.plus?.id; const { plus: _plus, ...modelWithoutPlus } = (config.model ?? {}) as Record< string, unknown >; // Don't carry a Plus path into the Custom tab — it would silently re-save // the same Plus model when the user thinks they switched modes. if ( typeof modelWithoutPlus.path === "string" && modelWithoutPlus.path.startsWith("plus://") ) { 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 [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; const currentDetectorType = useMemo(() => { if (!state) return undefined; const values = Object.values(state.detectors ?? {}); if (values.length === 0) return undefined; const first = values[0] as { type?: string } | undefined; return first?.type; }, [state]); 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(() => { const detectorsPending = childPending["detectors"]; if (detectorsPending) { setState((prev) => prev ? { ...prev, detectors: detectorsPending } : prev, ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [childPending["detectors"]]); useEffect(() => { const modelPending = childPending["model"]; if (modelPending) { setState((prev) => prev ? { ...prev, customModel: modelPending } : prev, ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [childPending["model"]]); useEffect(() => { if (!config || snapshot !== null) return; const initial = deriveInitialState(config); setSnapshot(initial); setState(initial); }, [config, snapshot]); const isDirty = useMemo(() => { if (!state || !snapshot) return false; return JSON.stringify(state) !== JSON.stringify(snapshot); }, [state, snapshot]); 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 detectorChanged = !isEqual(state.detectors, snapshot.detectors); const tabChanged = state.modelTab !== snapshot.modelTab; const modelPayload = state.modelTab === "plus" ? { path: `plus://${state.plusModelId}` } : state.customModel; setIsSaving(true); try { if (tabChanged) { await axios.put("config/set", { requires_restart: 0, config_data: { model: null }, }); } await axios.put("config/set", { requires_restart: detectorChanged ? 1 : 0, config_data: { detectors: state.detectors, model: modelPayload, }, }); await globalMutate("config"); await globalMutate("config/raw_paths"); // Re-derive snapshot from the freshly saved state so isDirty resets. setSnapshot({ ...state }); setChildPending({}); if (detectorChanged) { toast.success(t("detectorsAndModel.toast.saveSuccessRestart"), { position: "top-center", action: ( ), }); } else { toast.success(t("detectorsAndModel.toast.saveSuccess"), { position: "top-center", }); } } 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" }); // Re-sync the config cache in case the two-step PUT left the backend // ahead of the frontend (e.g. step 1 cleared `model` but step 2 failed). await globalMutate("config"); } finally { setIsSaving(false); } }, [state, snapshot, globalMutate, t]); const onUndo = useCallback(() => { if (snapshot) { setState(snapshot); setChildPending({}); } }, [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 && (
, 1: , }} />
)} setState((prev) => prev ? { ...prev, modelTab: value as ModelTab } : prev, ) } > {t("detectorsAndModel.tabs.plus")} {t("detectorsAndModel.tabs.custom")} {!plusEnabled ? (

{t("detectorsAndModel.plusModel.plusDisabled")}

) : (
{t("frigatePlus.modelInfo.filter.ariaLabel")}
)}
{isDirty && ( {t("unsavedChanges", { ns: "views/settings" })} )}
{isDirty && ( )}
setRestartDialogOpen(false)} onRestart={() => sendRestart("restart")} />
); }