mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-27 17:17:40 +03:00
frontend and i18n keys
This commit is contained in:
parent
fbc9c2c101
commit
e759557a4f
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 <strong>{{triggerName}}</strong> 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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
372
web/src/components/overlay/CreateTriggerDialog.tsx
Normal file
372
web/src/components/overlay/CreateTriggerDialog.tsx
Normal file
@ -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<FrigateConfig>("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<z.infer<typeof formSchema>>({
|
||||
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<typeof formSchema>) => {
|
||||
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 (
|
||||
<Dialog open={show} onOpenChange={onCancel}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(
|
||||
trigger
|
||||
? "triggers.dialog.editTrigger.title"
|
||||
: "triggers.dialog.createTrigger.title",
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
trigger
|
||||
? "triggers.dialog.editTrigger.desc"
|
||||
: "triggers.dialog.createTrigger.desc",
|
||||
{ camera: selectedCamera },
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-5 py-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("triggers.dialog.form.name.title")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("triggers.dialog.form.name.placeholder")}
|
||||
className="h-10"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("triggers.dialog.form.type.title")}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"triggers.dialog.form.type.placeholder",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="image">
|
||||
{t("triggers.type.image")}
|
||||
</SelectItem>
|
||||
<SelectItem value="text">
|
||||
{t("triggers.type.text")}
|
||||
</SelectItem>
|
||||
<SelectItem value="both">
|
||||
{t("triggers.type.both")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("triggers.dialog.form.content.title")}
|
||||
</FormLabel>
|
||||
{form.watch("type") === "image" ? (
|
||||
<FormControl>
|
||||
<ImagePicker
|
||||
selectedImageId={field.value}
|
||||
setSelectedImageId={field.onChange}
|
||||
camera={selectedCamera}
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"triggers.dialog.form.content.textPlaceholder",
|
||||
)}
|
||||
className="h-10"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="threshold"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("triggers.dialog.form.threshold.title")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
placeholder="0.50"
|
||||
className="h-10"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
field.onChange(isNaN(value) ? 0 : value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="actions"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("triggers.dialog.form.actions.title")}
|
||||
</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{["alert", "notification"].map((action) => (
|
||||
<div key={action} className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={form
|
||||
.watch("actions")
|
||||
.includes(action as "alert" | "notification")}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentActions = form.getValues("actions");
|
||||
if (checked) {
|
||||
form.setValue("actions", [
|
||||
...currentActions,
|
||||
action as "alert" | "notification",
|
||||
]);
|
||||
} else {
|
||||
form.setValue(
|
||||
"actions",
|
||||
currentActions.filter((a) => a !== action),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal">
|
||||
{t(`triggers.actions.${action}`)}
|
||||
</FormLabel>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex gap-2 pt-2 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
disabled={isLoading}
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
className="flex flex-1"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator />
|
||||
<span>{t("button.saving", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
68
web/src/components/overlay/DeleteTriggerDialog.tsx
Normal file
68
web/src/components/overlay/DeleteTriggerDialog.tsx
Normal file
@ -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 (
|
||||
<Dialog open={show} onOpenChange={onCancel}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("triggers.dialog.deleteTrigger.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans
|
||||
ns={"views/settings"}
|
||||
values={{ triggerName }}
|
||||
components={{ strong: <span className="font-medium" /> }}
|
||||
>
|
||||
triggers.dialog.deleteTrigger.desc
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
<div className="flex flex-row gap-2 pt-5">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
className="flex flex-1"
|
||||
onClick={onDelete}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
175
web/src/components/overlay/ImagePicker.tsx
Normal file
175
web/src/components/overlay/ImagePicker.tsx
Normal file
@ -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<HTMLDivElement>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const { data: events } = useSWR<Event[]>(`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 (
|
||||
<div ref={containerRef}>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
{!selectedImageId || !selectedImage ? (
|
||||
<Button
|
||||
className="mt-2 w-full text-muted-foreground"
|
||||
aria-label={t("imagePicker.selectImage")}
|
||||
>
|
||||
{t("imagePicker.selectImage")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="hover:cursor-pointer">
|
||||
<div className="my-3 flex w-full flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<img
|
||||
src={
|
||||
selectedImage.has_snapshot
|
||||
? `${apiHost}api/events/${selectedImage.id}/snapshot.jpg`
|
||||
: `${apiHost}api/events/${selectedImage.id}/thumbnail.webp`
|
||||
}
|
||||
alt={selectedImage.label}
|
||||
className="h-8 w-8 rounded object-cover"
|
||||
/>
|
||||
<div className="text-sm">
|
||||
{selectedImage.label}
|
||||
{selectedImage.sub_label
|
||||
? ` (${selectedImage.sub_label})`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
<IoClose
|
||||
className="mx-2 hover:cursor-pointer"
|
||||
onClick={() => {
|
||||
if (setSelectedImageId) {
|
||||
setSelectedImageId("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="top"
|
||||
container={containerRef.current}
|
||||
className="flex max-h-[50dvh] w-full flex-col overflow-y-hidden md:max-h-[30dvh]"
|
||||
>
|
||||
<div className="mb-3 flex flex-row items-center justify-between">
|
||||
<Heading as="h4">{t("imagePicker.selectImage")}</Heading>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
<IoClose
|
||||
size={15}
|
||||
className="hover:cursor-pointer"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("imagePicker.search.placeholder")}
|
||||
className="text-md mb-3 md:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<div className="scrollbar-container flex h-full flex-col overflow-y-auto">
|
||||
<div className="grid grid-cols-3 gap-2 pr-1">
|
||||
{images.length === 0 ? (
|
||||
<div className="col-span-3 text-center text-sm text-muted-foreground">
|
||||
{t("imagePicker.noImages")}
|
||||
</div>
|
||||
) : (
|
||||
images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className={cn(
|
||||
"flex flex-row items-center justify-center rounded-lg p-1 hover:cursor-pointer",
|
||||
selectedImageId === image.id
|
||||
? "bg-selected text-white"
|
||||
: "hover:bg-secondary-foreground",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
// image.has_snapshot
|
||||
// ? `${apiHost}api/events/${image.id}/snapshot.jpg`
|
||||
// :
|
||||
`${apiHost}api/events/${image.id}/thumbnail.webp`
|
||||
}
|
||||
alt={image.label}
|
||||
className="h-32 w-32 rounded object-cover"
|
||||
onClick={() => handleImageSelect(image.id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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") && (
|
||||
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
||||
{page == "masksAndZones" && (
|
||||
<ZoneMaskFilterButton
|
||||
@ -274,6 +277,12 @@ export default function Settings() {
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
/>
|
||||
)}
|
||||
{page === "triggers" && (
|
||||
<TriggerView
|
||||
selectedCamera={selectedCamera}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
/>
|
||||
)}
|
||||
{page == "users" && <AuthenticationView />}
|
||||
{page == "notifications" && (
|
||||
<NotificationView setUnsavedChanges={setUnsavedChanges} />
|
||||
|
||||
@ -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;
|
||||
|
||||
10
web/src/types/trigger.ts
Normal file
10
web/src/types/trigger.ts
Normal file
@ -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[];
|
||||
};
|
||||
465
web/src/views/settings/TriggerView.tsx
Normal file
465
web/src/views/settings/TriggerView.tsx
Normal file
@ -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<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export default function TriggerView({
|
||||
selectedCamera,
|
||||
setUnsavedChanges,
|
||||
}: TriggerViewProps) {
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [selectedTrigger, setSelectedTrigger] = useState<Trigger | null>(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 (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||
<div className="mb-5 flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start">
|
||||
<Heading as="h3" className="my-2">
|
||||
{t("triggers.management.title")}
|
||||
</Heading>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("triggers.management.desc", { camera: selectedCamera })}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="flex items-center gap-2 self-start sm:self-auto"
|
||||
aria-label={t("triggers.addTrigger")}
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setSelectedTrigger(null);
|
||||
setShowCreate(true);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
{t("triggers.addTrigger")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="scrollbar-container flex-1 overflow-hidden rounded-lg border border-border bg-background_alt">
|
||||
<div className="h-full overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">
|
||||
{t("triggers.table.name")}
|
||||
</TableHead>
|
||||
<TableHead>{t("triggers.table.type")}</TableHead>
|
||||
<TableHead>{t("triggers.table.content")}</TableHead>
|
||||
<TableHead>{t("triggers.table.threshold")}</TableHead>
|
||||
<TableHead>{t("triggers.table.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{triggers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-24 text-center">
|
||||
{t("triggers.table.noTriggers")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
triggers.map((trigger) => (
|
||||
<TableRow key={trigger.name} className="group">
|
||||
<TableCell className="font-medium">
|
||||
{trigger.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
trigger.type === "image" ? "default" : "outline"
|
||||
}
|
||||
>
|
||||
{t(`triggers.type.${trigger.type}`)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{trigger.type === "image"
|
||||
? trigger.data
|
||||
: trigger.data.length > 30
|
||||
? `${trigger.data.substring(0, 30)}...`
|
||||
: trigger.data}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{trigger.threshold.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{trigger.actions
|
||||
.map((action) => t(`triggers.actions.${action}`))
|
||||
.join(", ")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2"
|
||||
onClick={() => {
|
||||
setSelectedTrigger(trigger);
|
||||
setShowCreate(true);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<LuPencil className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
{t("triggers.table.edit")}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("triggers.table.edit")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-8 px-2"
|
||||
onClick={() => {
|
||||
setSelectedTrigger(trigger);
|
||||
setShowDelete(true);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<LuTrash className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("triggers.table.deleteTrigger")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CreateTriggerDialog
|
||||
show={showCreate}
|
||||
trigger={selectedTrigger}
|
||||
selectedCamera={selectedCamera}
|
||||
onCreate={onCreate}
|
||||
onEdit={onEdit}
|
||||
onCancel={() => {
|
||||
setShowCreate(false);
|
||||
setSelectedTrigger(null);
|
||||
setUnsavedChanges(false);
|
||||
}}
|
||||
/>
|
||||
<DeleteTriggerDialog
|
||||
show={showDelete}
|
||||
triggerName={selectedTrigger?.name ?? ""}
|
||||
onCancel={() => {
|
||||
setShowDelete(false);
|
||||
setSelectedTrigger(null);
|
||||
setUnsavedChanges(false);
|
||||
}}
|
||||
onDelete={() => onDelete(selectedTrigger?.name ?? "")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user