import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Toaster, toast } from "sonner"; import useSWR from "swr"; import axios from "axios"; import { Button } from "@/components/ui/button"; import Heading from "@/components/ui/heading"; import { Badge } from "@/components/ui/badge"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { LuPlus, LuTrash, LuPencil, LuSearch } from "react-icons/lu"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import CreateTriggerDialog from "@/components/overlay/CreateTriggerDialog"; import DeleteTriggerDialog from "@/components/overlay/DeleteTriggerDialog"; import { FrigateConfig } from "@/types/frigateConfig"; import { Trigger, TriggerAction, TriggerType } from "@/types/trigger"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { cn } from "@/lib/utils"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { Link } from "react-router-dom"; import { useTriggers } from "@/api/ws"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; type ConfigSetBody = { requires_restart: number; config_data: { cameras: { [key: string]: { semantic_search?: { triggers?: { [key: string]: | { enabled: boolean; type: string; data: string; threshold: number; actions: string[]; } | ""; }; }; }; }; }; update_topic?: string; }; type TriggerEmbeddingBody = { type: TriggerType; data: string; threshold: number; }; type TriggerViewProps = { selectedCamera: string; setUnsavedChanges: React.Dispatch>; }; export default function TriggerView({ selectedCamera, setUnsavedChanges, }: TriggerViewProps) { const { t } = useTranslation("views/settings"); const { data: config, mutate: updateConfig } = useSWR("config"); const { data: trigger_status, mutate } = useSWR( `/triggers/status/${selectedCamera}`, { revalidateOnFocus: false, }, ); const [showCreate, setShowCreate] = useState(false); const [showDelete, setShowDelete] = useState(false); const [selectedTrigger, setSelectedTrigger] = useState(null); const [triggeredTrigger, setTriggeredTrigger] = useState(); const [isLoading, setIsLoading] = useState(false); const cameraName = useCameraFriendlyName(selectedCamera); const triggers = useMemo(() => { if ( !config || !selectedCamera || !config.cameras[selectedCamera]?.semantic_search?.triggers ) { return []; } return Object.entries( config.cameras[selectedCamera].semantic_search.triggers, ).map(([name, trigger]) => ({ enabled: trigger.enabled, name, type: trigger.type, data: trigger.data, threshold: trigger.threshold, actions: trigger.actions, })); }, [config, selectedCamera]); // watch websocket for updates const { payload: triggers_status_ws } = useTriggers(); useEffect(() => { if (!triggers_status_ws) return; mutate(); setTriggeredTrigger(triggers_status_ws.name); const target = document.querySelector( `#trigger-${triggers_status_ws.name}`, ); if (target) { target.scrollIntoView({ block: "center", behavior: "smooth", inline: "nearest", }); const ring = target.querySelector(".trigger-ring"); if (ring) { ring.classList.add(`outline-selected`); ring.classList.remove("outline-transparent"); const timeout = setTimeout(() => { ring.classList.remove(`outline-selected`); ring.classList.add("outline-transparent"); }, 3000); return () => clearTimeout(timeout); } } }, [triggers_status_ws, selectedCamera, mutate]); useEffect(() => { document.title = t("triggers.documentTitle"); }, [t]); const saveToConfig = useCallback( (trigger: Trigger, isEdit: boolean) => { setIsLoading(true); const { enabled, name, type, data, threshold, actions } = trigger; const embeddingBody: TriggerEmbeddingBody = { type, data, threshold }; const embeddingUrl = isEdit ? `/trigger/embedding/${selectedCamera}/${name}` : `/trigger/embedding?camera=${selectedCamera}&name=${name}`; const embeddingMethod = isEdit ? axios.put : axios.post; embeddingMethod(embeddingUrl, embeddingBody) .then((embeddingResponse) => { if (embeddingResponse.data.success) { const configBody: ConfigSetBody = { requires_restart: 0, config_data: { cameras: { [selectedCamera]: { semantic_search: { triggers: { [name]: { enabled, type, data, threshold, actions, }, }, }, }, }, }, update_topic: `config/cameras/${selectedCamera}/semantic_search`, }; return axios .put("config/set", configBody) .then((configResponse) => { if (configResponse.status === 200) { updateConfig(); toast.success( t( isEdit ? "triggers.toast.success.updateTrigger" : "triggers.toast.success.createTrigger", { name }, ), { position: "top-center" }, ); setUnsavedChanges(false); } else { throw new Error(configResponse.statusText); } }); } else { throw new Error(embeddingResponse.data.message); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("toast.save.error.title", { errorMessage, ns: "common" }), { position: "top-center" }, ); }) .finally(() => { setIsLoading(false); setShowCreate(false); }); }, [t, updateConfig, selectedCamera, setUnsavedChanges], ); const onCreate = useCallback( ( enabled: boolean, name: string, type: TriggerType, data: string, threshold: number, actions: TriggerAction[], ) => { setUnsavedChanges(true); saveToConfig({ enabled, name, type, data, threshold, actions }, false); }, [saveToConfig, setUnsavedChanges], ); const onEdit = useCallback( (trigger: Trigger) => { setUnsavedChanges(true); setIsLoading(true); if (selectedTrigger?.name && selectedTrigger.name !== trigger.name) { // Handle rename: delete old trigger, update config, then save new trigger axios .delete( `/trigger/embedding/${selectedCamera}/${selectedTrigger.name}`, ) .then((embeddingResponse) => { if (!embeddingResponse.data.success) { throw new Error(embeddingResponse.data.message); } const deleteConfigBody: ConfigSetBody = { requires_restart: 0, config_data: { cameras: { [selectedCamera]: { semantic_search: { triggers: { [selectedTrigger.name]: "", }, }, }, }, }, update_topic: `config/cameras/${selectedCamera}/semantic_search`, }; return axios.put("config/set", deleteConfigBody); }) .then((configResponse) => { if (configResponse.status !== 200) { throw new Error(configResponse.statusText); } // Save new trigger saveToConfig(trigger, false); }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("toast.save.error.title", { errorMessage, ns: "common" }), { position: "top-center" }, ); setIsLoading(false); }); } else { // Regular update without rename saveToConfig(trigger, true); } setSelectedTrigger(null); }, [t, saveToConfig, selectedCamera, selectedTrigger, setUnsavedChanges], ); const onDelete = useCallback( (name: string) => { setUnsavedChanges(true); setIsLoading(true); axios .delete(`/trigger/embedding/${selectedCamera}/${name}`) .then((embeddingResponse) => { if (embeddingResponse.data.success) { const configBody: ConfigSetBody = { requires_restart: 0, config_data: { cameras: { [selectedCamera]: { semantic_search: { triggers: { [name]: "", }, }, }, }, }, update_topic: `config/cameras/${selectedCamera}/semantic_search`, }; return axios .put("config/set", configBody) .then((configResponse) => { if (configResponse.status === 200) { updateConfig(); toast.success( t("triggers.toast.success.deleteTrigger", { name }), { position: "top-center", }, ); setUnsavedChanges(false); } else { throw new Error(configResponse.statusText); } }); } else { throw new Error(embeddingResponse.data.message); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("triggers.toast.error.deleteTriggerFailed", { errorMessage }), { position: "top-center" }, ); }) .finally(() => { setShowDelete(false); setIsLoading(false); }); }, [t, updateConfig, selectedCamera, setUnsavedChanges], ); useEffect(() => { if (selectedCamera) { setSelectedTrigger(null); setShowCreate(false); setShowDelete(false); setUnsavedChanges(false); } }, [selectedCamera, setUnsavedChanges]); // for adding a trigger with event id via explore context menu useSearchEffect("event_id", (eventId: string) => { if (!config || isLoading) { return false; } setShowCreate(true); setSelectedTrigger({ enabled: true, name: "", type: "thumbnail", data: eventId, threshold: 0.5, actions: [], }); return true; }); if (!config || !selectedCamera) { return (
); } return (
{t("triggers.management.title")}

{t("triggers.management.desc", { camera: cameraName, })}

{triggers.length === 0 ? (

{t("triggers.table.noTriggers")}

) : (
{triggers.map((trigger) => (

{trigger.name}

{t(`triggers.type.${trigger.type}`)}
{t("triggers.table.lastTriggered")}:{" "} {trigger_status && trigger_status.triggers[trigger.name] ?.last_triggered ? formatUnixTimestampToDateTime( trigger_status.triggers[trigger.name] ?.last_triggered, { timezone: config.ui.timezone, date_format: config.ui.time_format == "24hour" ? t( "time.formattedTimestamp2.24hour", { ns: "common", }, ) : t( "time.formattedTimestamp2.12hour", { ns: "common", }, ), time_style: "medium", date_style: "medium", }, ) : "Never"} {t("details.item.button.viewInExplore", { ns: "views/explore", })}

{t("triggers.table.edit")}

{t("triggers.table.deleteTrigger")}

))}
)}
{ setShowCreate(false); setSelectedTrigger(null); setUnsavedChanges(false); }} /> { setShowDelete(false); setSelectedTrigger(null); setUnsavedChanges(false); }} onDelete={() => onDelete(selectedTrigger?.name ?? "")} />
); }