diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index 8b2dc0b88..4a6721daa 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -109,5 +109,12 @@ "markAsReviewed": "Mark as reviewed", "deleteNow": "Delete Now" } + }, + "imagePicker": { + "selectImage": "Select a tracked object's thumbnail or snapshot", + "search": { + "placeholder": "Search by label..." + }, + "noImages": "No thumbnails found for this camera" } } diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index f27ab51b6..900257afb 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -644,5 +644,94 @@ "success": "Frigate+ settings have been saved. Restart Frigate to apply changes.", "error": "Failed to save config changes: {{errorMessage}}" } + }, + "triggers": { + "documentTitle": "Triggers", + "management": { + "title": "Trigger Management", + "desc": "Manage triggers for camera '{{camera}}' to detect specific events." + }, + "addTrigger": "Add Trigger", + "table": { + "name": "Name", + "type": "Type", + "content": "Content", + "threshold": "Threshold", + "actions": "Actions", + "noTriggers": "No triggers configured for this camera.", + "edit": "Edit", + "deleteTrigger": "Delete Trigger" + }, + "type": { + "image": "Image", + "text": "Text", + "both": "Both" + }, + "actions": { + "alert": "Mark as Alert", + "notification": "Send Notification" + }, + "dialog": { + "createTrigger": { + "title": "Create Trigger", + "desc": "Create a new trigger for camera '{{camera}}'." + }, + "editTrigger": { + "title": "Edit Trigger", + "desc": "Edit the settings for an existing trigger on camera '{{camera}}'." + }, + "deleteTrigger": { + "title": "Delete Trigger", + "desc": "Are you sure you want to delete the trigger {{triggerName}} from camera '{{camera}}'? This action cannot be undone." + }, + "form": { + "name": { + "title": "Trigger Name", + "placeholder": "Enter trigger name", + "error": { + "minLength": "Name must be at least 2 characters long.", + "invalidCharacters": "Name can only contain letters, numbers, underscores, and hyphens.", + "alreadyExists": "A trigger with this name already exists for this camera." + } + }, + "type": { + "title": "Trigger Type", + "placeholder": "Select trigger type" + }, + "content": { + "title": "Content", + "imagePlaceholder": "Select an image", + "textPlaceholder": "Enter text content", + "error": { + "required": "Content is required." + } + }, + "threshold": { + "title": "Threshold", + "error": { + "min": "Threshold must be at least 0", + "max": "Threshold must be at most 1" + } + }, + "actions": { + "title": "Actions", + "error": { + "min": "At least one action must be selected." + } + } + } + }, + "toast": { + "success": { + "createTrigger": "Trigger '{{name}}' created successfully.", + "updateTrigger": "Trigger '{{name}}' updated successfully.", + "deleteTrigger": "Trigger '{{name}}' deleted successfully." + }, + "error": { + "createTriggerFailed": "Failed to create trigger: {{errorMessage}}", + "updateTriggerFailed": "Failed to update trigger: {{errorMessage}}", + "deleteTriggerFailed": "Failed to delete trigger: {{errorMessage}}" + } + } } } diff --git a/web/src/components/overlay/CreateTriggerDialog.tsx b/web/src/components/overlay/CreateTriggerDialog.tsx new file mode 100644 index 000000000..fd63bd3ad --- /dev/null +++ b/web/src/components/overlay/CreateTriggerDialog.tsx @@ -0,0 +1,372 @@ +import { useEffect, useState, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import useSWR from "swr"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { FrigateConfig } from "@/types/frigateConfig"; +import ImagePicker from "@/components/overlay/ImagePicker"; +import { Trigger, TriggerAction, TriggerType } from "@/types/trigger"; + +type CreateTriggerDialogProps = { + show: boolean; + trigger: Trigger | null; + selectedCamera: string; + onCreate: ( + name: string, + type: TriggerType, + data: string, + threshold: number, + actions: TriggerAction[], + ) => void; + onEdit: (trigger: Trigger) => void; + onCancel: () => void; +}; + +export default function CreateTriggerDialog({ + show, + trigger, + selectedCamera, + onCreate, + onEdit, + onCancel, +}: CreateTriggerDialogProps) { + const { t } = useTranslation("views/settings"); + const [isLoading, setIsLoading] = useState(false); + const { data: config } = useSWR("config"); + + const existingTriggerNames = useMemo(() => { + if ( + !config || + !selectedCamera || + !config.cameras[selectedCamera]?.semantic_search?.triggers + ) { + return []; + } + return Object.keys(config.cameras[selectedCamera].semantic_search.triggers); + }, [config, selectedCamera]); + + const formSchema = z.object({ + name: z + .string() + .min(2, t("triggers.dialog.form.name.error.minLength")) + .regex( + /^[a-zA-Z0-9_-]+$/, + t("triggers.dialog.form.name.error.invalidCharacters"), + ) + .refine( + (value) => + !existingTriggerNames.includes(value) || value === trigger?.name, + t("triggers.dialog.form.name.error.alreadyExists"), + ), + type: z.enum(["image", "text", "both"]), + data: z.string().min(1, t("triggers.dialog.form.content.error.required")), + threshold: z + .number() + .min(0, t("triggers.dialog.form.threshold.error.min")) + .max(1, t("triggers.dialog.form.threshold.error.max")), + actions: z + .array(z.enum(["alert", "notification"])) + .min(1, t("triggers.dialog.form.actions.error.min")), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + name: trigger?.name ?? "", + type: trigger?.type ?? "text", + data: trigger?.data ?? "", + threshold: trigger?.threshold ?? 0.5, + actions: trigger?.actions ?? ["alert"], + }, + }); + + const onSubmit = async (values: z.infer) => { + setIsLoading(true); + if (trigger) { + onEdit({ ...values }); + } else { + onCreate( + values.name, + values.type, + values.data, + values.threshold, + values.actions, + ); + } + setIsLoading(false); + }; + + useEffect(() => { + if (!show) { + form.reset({ + name: "", + type: "text", + data: "", + threshold: 0.5, + actions: ["alert"], + }); + } else if (trigger) { + form.reset({ + name: trigger.name, + type: trigger.type, + data: trigger.data, + threshold: trigger.threshold, + actions: trigger.actions, + }); + } + }, [show, trigger, form]); + + const handleCancel = () => { + form.reset(); + onCancel(); + }; + + return ( + + + + + {t( + trigger + ? "triggers.dialog.editTrigger.title" + : "triggers.dialog.createTrigger.title", + )} + + + {t( + trigger + ? "triggers.dialog.editTrigger.desc" + : "triggers.dialog.createTrigger.desc", + { camera: selectedCamera }, + )} + + + +
+ + ( + + {t("triggers.dialog.form.name.title")} + + + + + + )} + /> + + ( + + {t("triggers.dialog.form.type.title")} + + + + )} + /> + + ( + + + {t("triggers.dialog.form.content.title")} + + {form.watch("type") === "image" ? ( + + + + ) : ( + + + + )} + + + )} + /> + + ( + + + {t("triggers.dialog.form.threshold.title")} + + + { + const value = parseFloat(e.target.value); + field.onChange(isNaN(value) ? 0 : value); + }} + /> + + + + )} + /> + + ( + + + {t("triggers.dialog.form.actions.title")} + +
+ {["alert", "notification"].map((action) => ( +
+ + { + const currentActions = form.getValues("actions"); + if (checked) { + form.setValue("actions", [ + ...currentActions, + action as "alert" | "notification", + ]); + } else { + form.setValue( + "actions", + currentActions.filter((a) => a !== action), + ); + } + }} + /> + + + {t(`triggers.actions.${action}`)} + +
+ ))} +
+ +
+ )} + /> + + +
+
+ + +
+
+
+ + +
+
+ ); +} diff --git a/web/src/components/overlay/DeleteTriggerDialog.tsx b/web/src/components/overlay/DeleteTriggerDialog.tsx new file mode 100644 index 000000000..cf249e282 --- /dev/null +++ b/web/src/components/overlay/DeleteTriggerDialog.tsx @@ -0,0 +1,68 @@ +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Trans } from "react-i18next"; + +type DeleteTriggerDialogProps = { + show: boolean; + triggerName: string; + onCancel: () => void; + onDelete: () => void; +}; + +export default function DeleteTriggerDialog({ + show, + triggerName, + onCancel, + onDelete, +}: DeleteTriggerDialogProps) { + const { t } = useTranslation("views/settings"); + + return ( + + + + {t("triggers.dialog.deleteTrigger.title")} + + }} + > + triggers.dialog.deleteTrigger.desc + + + + +
+
+ + +
+
+
+
+
+ ); +} diff --git a/web/src/components/overlay/ImagePicker.tsx b/web/src/components/overlay/ImagePicker.tsx new file mode 100644 index 000000000..55cd145be --- /dev/null +++ b/web/src/components/overlay/ImagePicker.tsx @@ -0,0 +1,175 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import useSWR from "swr"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { IoClose } from "react-icons/io5"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import Heading from "@/components/ui/heading"; +import { cn } from "@/lib/utils"; +import { Event } from "@/types/event"; +import { useApiHost } from "@/api"; + +type ImagePickerProps = { + selectedImageId?: string; + setSelectedImageId?: (id: string) => void; + camera: string; +}; + +export default function ImagePicker({ + selectedImageId, + setSelectedImageId, + camera, +}: ImagePickerProps) { + const { t } = useTranslation(["components/dialog"]); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const [searchTerm, setSearchTerm] = useState(""); + + const { data: events } = useSWR(`events?camera=${camera}&limit=50`, { + revalidateOnFocus: false, + }); + const apiHost = useApiHost(); + + const images = useMemo(() => { + if (!events) return []; + return events.filter( + (event) => + (event.label.toLowerCase().includes(searchTerm.toLowerCase()) || + (event.sub_label && + event.sub_label.toLowerCase().includes(searchTerm.toLowerCase())) || + searchTerm === "") && + event.camera === camera, + ); + }, [events, searchTerm, camera]); + + const selectedImage = useMemo( + () => images.find((img) => img.id === selectedImageId), + [images, selectedImageId], + ); + + const handleImageSelect = useCallback( + (id: string) => { + if (setSelectedImageId) { + setSelectedImageId(id); + } + setSearchTerm(""); + setOpen(false); + }, + [setSelectedImageId], + ); + + return ( +
+ { + setOpen(open); + }} + > + + {!selectedImageId || !selectedImage ? ( + + ) : ( +
+
+
+ {selectedImage.label} +
+ {selectedImage.label} + {selectedImage.sub_label + ? ` (${selectedImage.sub_label})` + : ""} +
+
+ { + if (setSelectedImageId) { + setSelectedImageId(""); + } + }} + /> +
+
+ )} +
+ +
+ {t("imagePicker.selectImage")} + + { + setOpen(false); + }} + /> +
+ setSearchTerm(e.target.value)} + /> +
+
+ {images.length === 0 ? ( +
+ {t("imagePicker.noImages")} +
+ ) : ( + images.map((image) => ( +
+ {image.label} handleImageSelect(image.id)} + /> +
+ )) + )} +
+
+
+
+
+ ); +} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 9966b6e11..0f3eb52fd 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -45,6 +45,7 @@ import { isInIframe } from "@/utils/isIFrame"; import { isPWA } from "@/utils/isPWA"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { useTranslation } from "react-i18next"; +import TriggerView from "@/views/settings/TriggerView"; const allSettingsViews = [ "ui", @@ -52,6 +53,7 @@ const allSettingsViews = [ "cameras", "masksAndZones", "motionTuner", + "triggers", "debug", "users", "notifications", @@ -229,7 +231,8 @@ export default function Settings() { {(page == "debug" || page == "cameras" || page == "masksAndZones" || - page == "motionTuner") && ( + page == "motionTuner" || + page == "triggers") && (
{page == "masksAndZones" && ( )} + {page === "triggers" && ( + + )} {page == "users" && } {page == "notifications" && ( diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 7d4c27794..9668ad68d 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -1,4 +1,5 @@ import { IconName } from "@/components/icons/IconPicker"; +import { TriggerAction, TriggerType } from "./trigger"; export interface UiConfig { timezone?: string; @@ -220,6 +221,16 @@ export interface CameraConfig { rtmp: { enabled: boolean; }; + semantic_search: { + triggers: { + [triggerName: string]: { + type: TriggerType; + data: string; + threshold: number; + actions: TriggerAction[]; + }; + }; + }; snapshots: { bounding_box: boolean; clean_copy: boolean; diff --git a/web/src/types/trigger.ts b/web/src/types/trigger.ts new file mode 100644 index 000000000..cfb9bb612 --- /dev/null +++ b/web/src/types/trigger.ts @@ -0,0 +1,10 @@ +export type TriggerType = "image" | "text" | "both"; +export type TriggerAction = "alert" | "notification"; + +export type Trigger = { + name: string; + type: TriggerType; + data: string; + threshold: number; + actions: TriggerAction[]; +}; diff --git a/web/src/views/settings/TriggerView.tsx b/web/src/views/settings/TriggerView.tsx new file mode 100644 index 000000000..35b096326 --- /dev/null +++ b/web/src/views/settings/TriggerView.tsx @@ -0,0 +1,465 @@ +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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { LuPlus, LuTrash, LuPencil } 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"; + +type ConfigSetBody = { + requires_restart: number; + config_data: { + cameras: { + [key: string]: { + semantic_search?: { + triggers?: { + [key: string]: + | { + 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 [showCreate, setShowCreate] = useState(false); + const [showDelete, setShowDelete] = useState(false); + const [selectedTrigger, setSelectedTrigger] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + 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]) => ({ + name, + type: trigger.type, + data: trigger.data, + threshold: trigger.threshold, + actions: trigger.actions, + })); + }, [config, selectedCamera]); + + useEffect(() => { + document.title = t("triggers.documentTitle"); + }, [t]); + + const saveToConfig = useCallback( + (trigger: Trigger, isEdit: boolean) => { + setIsLoading(true); + const { 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]: { + 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); + }); + }, + [t, updateConfig, selectedCamera, setUnsavedChanges], + ); + + const onCreate = useCallback( + ( + name: string, + type: TriggerType, + data: string, + threshold: number, + actions: TriggerAction[], + ) => { + setUnsavedChanges(true); + saveToConfig({ name, type, data, threshold, actions }, false); + setShowCreate(false); + }, + [saveToConfig, setUnsavedChanges], + ); + + const onEdit = useCallback( + (trigger: Trigger) => { + setUnsavedChanges(true); + if (selectedTrigger?.name && selectedTrigger.name !== trigger.name) { + // Handle rename by deleting old trigger + axios + .delete( + `/trigger/embedding/${selectedCamera}/${selectedTrigger.name}`, + ) + .then((embeddingResponse) => { + if (embeddingResponse.data.success) { + return saveToConfig(trigger, true); + } 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" }, + ); + setIsLoading(false); + }); + } else { + saveToConfig(trigger, true); + } + setShowCreate(false); + 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) { + setShowDelete(false); + 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(() => { + setIsLoading(false); + }); + }, + [t, updateConfig, selectedCamera, setUnsavedChanges], + ); + + useEffect(() => { + if (selectedCamera) { + setSelectedTrigger(null); + setShowCreate(false); + setShowDelete(false); + setUnsavedChanges(false); + } + }, [selectedCamera, setUnsavedChanges]); + + if (!config || !selectedCamera) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+
+
+ + {t("triggers.management.title")} + +

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

+
+ +
+
+
+
+ + + + + {t("triggers.table.name")} + + {t("triggers.table.type")} + {t("triggers.table.content")} + {t("triggers.table.threshold")} + {t("triggers.table.actions")} + + + + {triggers.length === 0 ? ( + + + {t("triggers.table.noTriggers")} + + + ) : ( + triggers.map((trigger) => ( + + + {trigger.name} + + + + {t(`triggers.type.${trigger.type}`)} + + + + {trigger.type === "image" + ? trigger.data + : trigger.data.length > 30 + ? `${trigger.data.substring(0, 30)}...` + : trigger.data} + + + {trigger.threshold.toFixed(2)} + + + {trigger.actions + .map((action) => t(`triggers.actions.${action}`)) + .join(", ")} + + + +
+ + + + + +

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

+
+
+ + + + + +

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

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