import { useCallback, useEffect, useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Toaster, toast } from "sonner"; import useSWR from "swr"; import axios from "axios"; import { Button } from "@/components/ui/button"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import Heading from "@/components/ui/heading"; import { Badge } from "@/components/ui/badge"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { LuPlus, LuTrash, LuPencil, LuSearch, LuExternalLink, LuCircle, } from "react-icons/lu"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import TriggerWizardDialog from "@/components/trigger/TriggerWizardDialog"; 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"; import { CiCircleAlert } from "react-icons/ci"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { isDesktop } from "react-device-detect"; 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[]; friendly_name?: 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( config?.cameras[selectedCamera]?.semantic_search?.triggers && Object.keys(config.cameras[selectedCamera].semantic_search.triggers) .length > 0 ? `/triggers/status/${selectedCamera}` : null, { 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 isSemanticSearchEnabled = config?.semantic_search?.enabled ?? false; const { getLocaleDocUrl } = useDocDomain(); 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, friendly_name: trigger.friendly_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((prev) => { const current = prev || []; if (!current.includes(triggers_status_ws.name)) { const newTriggers = [...current, triggers_status_ws.name]; return newTriggers; } return current; }); const timeout = setTimeout(() => { setTriggeredTrigger((prev) => (prev || []).filter((name) => name !== triggers_status_ws.name), ); }, 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, friendly_name } = trigger; const embeddingBody: TriggerEmbeddingBody = { type, data, threshold }; const embeddingUrl = isEdit ? `/trigger/embedding/${selectedCamera}/${name}` : `/trigger/embedding?camera_name=${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, friendly_name, }, }, }, }, }, }, update_topic: `config/cameras/${selectedCamera}/semantic_search`, }; return axios .put("config/set", configBody) .then(async (configResponse) => { if (configResponse.status === 200) { await updateConfig(); const displayName = friendly_name && friendly_name !== "" ? `${friendly_name} (${name})` : name; toast.success( t( isEdit ? "triggers.toast.success.updateTrigger" : "triggers.toast.success.createTrigger", { name: displayName }, ), { 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); setSelectedTrigger(null); }); }, [t, updateConfig, selectedCamera, setUnsavedChanges], ); const onCreate = useCallback( ( enabled: boolean, name: string, type: TriggerType, data: string, threshold: number, actions: TriggerAction[], friendly_name: string, ) => { setUnsavedChanges(true); saveToConfig( { enabled, name, type, data, threshold, actions, friendly_name, }, 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); } }, [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(async (configResponse) => { if (configResponse.status === 200) { await updateConfig(); const friendly = config?.cameras?.[selectedCamera]?.semantic_search ?.triggers?.[name]?.friendly_name; const displayName = friendly && friendly !== "" ? `${friendly} (${name})` : name; toast.success( t("triggers.toast.success.deleteTrigger", { name: displayName, }), { 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, config], ); useEffect(() => { if (selectedCamera) { setSelectedTrigger(null); setShowCreate(false); setShowDelete(false); setUnsavedChanges(false); setTriggeredTrigger([]); } }, [selectedCamera, setUnsavedChanges]); // for adding a trigger with event id via explore context menu useSearchEffect("event_id", (eventId: string) => { if (!config || isLoading || !isSemanticSearchEnabled) { return false; } setShowCreate(true); setSelectedTrigger({ enabled: true, name: eventId, friendly_name: "", type: "thumbnail", data: eventId, threshold: 0.5, actions: [], }); return true; }); if (!config || !selectedCamera) { return (
); } return (
{!isSemanticSearchEnabled ? (
{t("triggers.management.title")}

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

{t("triggers.semanticSearch.title")} triggers.semanticSearch.desc
{t("readTheDocumentation", { ns: "common" })}{" "}
) : ( <>
{t("triggers.management.title")}

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

{triggers.length === 0 ? (

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

) : ( triggers.map((trigger) => (
{trigger.friendly_name || 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"} {trigger_status?.triggers[trigger.name] ?.triggering_event_id && ( )}

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

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

)) )}
{/* Desktop Table View */}
{t("name", { ns: "common" })} {t("triggers.table.type")} {t("triggers.table.lastTriggered")} {t("triggers.table.actions")} {triggers.length === 0 ? ( {t("triggers.table.noTriggers")} ) : ( triggers.map((trigger) => (
{trigger.friendly_name || trigger.name}
{t(`triggers.type.${trigger.type}`)} {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"} {trigger_status?.triggers[trigger.name] ?.triggering_event_id && ( {t("details.item.button.viewInExplore", { ns: "views/explore", })} )}

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

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

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