import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { LuExternalLink, LuFilter } from "react-icons/lu"; import axios from "axios"; import useSWR from "swr"; import { cn } from "@/lib/utils"; 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 >; return { detectors: (config.detectors ?? {}) as ConfigSectionData, modelTab, plusModelId: plusModelId ?? undefined, customModel: modelWithoutPlus as ConfigSectionData, }; }; export default function DetectorsAndModelSettingsView( _props: SettingsPageProps, ) { const { t } = useTranslation(["views/settings", "common"]); const { getLocaleDocUrl } = useDocDomain(); const { data: config } = useSWR("config"); const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; const [snapshot, setSnapshot] = useState(null); const [state, setState] = useState(null); const [isSaving, setIsSaving] = useState(false); 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 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); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDirty]); useEffect(() => { document.title = t("documentTitle.detectorsAndModel"); }, [t]); const onSave = useCallback(async () => { // implemented in Task 9 setIsSaving(true); setIsSaving(false); }, []); const onUndo = useCallback(() => { if (snapshot) setState(snapshot); }, [snapshot]); if (!config || !state) { return ; } const saveDisabled = !isDirty || isSaving || detectorStatus.hasValidationErrors || (state.modelTab === "custom" && modelStatus.hasValidationErrors); return (
{t("detectorsAndModel.title")}
{t("detectorsAndModel.description")}
{t("readTheDocumentation", { ns: "common" })}
{isDirty && ( {t("button.modified", { ns: "common", defaultValue: "Modified" })} )}
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 && ( )}
); }