This commit is contained in:
Josh Hawkins 2025-10-03 06:11:44 -05:00
parent 8f2cfb0544
commit 20091ea8b9
4 changed files with 285 additions and 185 deletions

View File

@ -60,6 +60,7 @@ type CreateTriggerDialogProps = {
data: string, data: string,
threshold: number, threshold: number,
actions: TriggerAction[], actions: TriggerAction[],
friendly_name: string,
) => void; ) => void;
onEdit: (trigger: Trigger) => void; onEdit: (trigger: Trigger) => void;
onCancel: () => void; onCancel: () => void;
@ -102,6 +103,7 @@ export default function CreateTriggerDialog({
!existingTriggerNames.includes(value) || value === trigger?.name, !existingTriggerNames.includes(value) || value === trigger?.name,
t("triggers.dialog.form.name.error.alreadyExists"), t("triggers.dialog.form.name.error.alreadyExists"),
), ),
friendly_name: z.string().optional(),
type: z.enum(["thumbnail", "description"]), type: z.enum(["thumbnail", "description"]),
data: z.string().min(1, t("triggers.dialog.form.content.error.required")), data: z.string().min(1, t("triggers.dialog.form.content.error.required")),
threshold: z threshold: z
@ -117,6 +119,7 @@ export default function CreateTriggerDialog({
defaultValues: { defaultValues: {
enabled: trigger?.enabled ?? true, enabled: trigger?.enabled ?? true,
name: trigger?.name ?? "", name: trigger?.name ?? "",
friendly_name: trigger?.friendly_name ?? "",
type: trigger?.type ?? "description", type: trigger?.type ?? "description",
data: trigger?.data ?? "", data: trigger?.data ?? "",
threshold: trigger?.threshold ?? 0.5, threshold: trigger?.threshold ?? 0.5,
@ -135,6 +138,7 @@ export default function CreateTriggerDialog({
values.data, values.data,
values.threshold, values.threshold,
values.actions, values.actions,
values.friendly_name ?? "",
); );
} }
}; };
@ -144,6 +148,7 @@ export default function CreateTriggerDialog({
form.reset({ form.reset({
enabled: true, enabled: true,
name: "", name: "",
friendly_name: "",
type: "description", type: "description",
data: "", data: "",
threshold: 0.5, threshold: 0.5,
@ -154,6 +159,7 @@ export default function CreateTriggerDialog({
{ {
enabled: trigger.enabled, enabled: trigger.enabled,
name: trigger.name, name: trigger.name,
friendly_name: trigger.friendly_name ?? "",
type: trigger.type, type: trigger.type,
data: trigger.data, data: trigger.data,
threshold: trigger.threshold, threshold: trigger.threshold,
@ -231,6 +237,31 @@ export default function CreateTriggerDialog({
)} )}
/> />
<FormField
control={form.control}
name="friendly_name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("triggers.dialog.form.friendly_name.title")}
</FormLabel>
<FormControl>
<Input
placeholder={t(
"triggers.dialog.form.friendly_name.placeholder",
)}
className="h-10"
{...field}
/>
</FormControl>
<FormDescription>
{t("triggers.dialog.form.friendly_name.description")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="enabled" name="enabled"

View File

@ -237,6 +237,7 @@ export interface CameraConfig {
data: string; data: string;
threshold: number; threshold: number;
actions: TriggerAction[]; actions: TriggerAction[];
friendly_name: string;
}; };
}; };
}; };

View File

@ -8,4 +8,5 @@ export type Trigger = {
data: string; data: string;
threshold: number; threshold: number;
actions: TriggerAction[]; actions: TriggerAction[];
friendly_name?: string;
}; };

View File

@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Toaster, toast } from "sonner"; import { Toaster, toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
import axios from "axios"; import axios from "axios";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
@ -12,7 +13,13 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { LuPlus, LuTrash, LuPencil, LuSearch } from "react-icons/lu"; import {
LuPlus,
LuTrash,
LuPencil,
LuSearch,
LuExternalLink,
} from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import CreateTriggerDialog from "@/components/overlay/CreateTriggerDialog"; import CreateTriggerDialog from "@/components/overlay/CreateTriggerDialog";
import DeleteTriggerDialog from "@/components/overlay/DeleteTriggerDialog"; import DeleteTriggerDialog from "@/components/overlay/DeleteTriggerDialog";
@ -24,6 +31,8 @@ import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTriggers } from "@/api/ws"; import { useTriggers } from "@/api/ws";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { CiCircleAlert } from "react-icons/ci";
import { useDocDomain } from "@/hooks/use-doc-domain";
type ConfigSetBody = { type ConfigSetBody = {
requires_restart: number; requires_restart: number;
@ -39,6 +48,7 @@ type ConfigSetBody = {
data: string; data: string;
threshold: number; threshold: number;
actions: string[]; actions: string[];
friendly_name?: string;
} }
| ""; | "";
}; };
@ -80,6 +90,10 @@ export default function TriggerView({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const cameraName = useCameraFriendlyName(selectedCamera); const cameraName = useCameraFriendlyName(selectedCamera);
const isSemanticSearchEnabled = config?.semantic_search?.enabled ?? false;
const { getLocaleDocUrl } = useDocDomain();
const triggers = useMemo(() => { const triggers = useMemo(() => {
if ( if (
!config || !config ||
@ -93,6 +107,7 @@ export default function TriggerView({
).map(([name, trigger]) => ({ ).map(([name, trigger]) => ({
enabled: trigger.enabled, enabled: trigger.enabled,
name, name,
friendly_name: trigger.friendly_name,
type: trigger.type, type: trigger.type,
data: trigger.data, data: trigger.data,
threshold: trigger.threshold, threshold: trigger.threshold,
@ -139,7 +154,8 @@ export default function TriggerView({
const saveToConfig = useCallback( const saveToConfig = useCallback(
(trigger: Trigger, isEdit: boolean) => { (trigger: Trigger, isEdit: boolean) => {
setIsLoading(true); setIsLoading(true);
const { enabled, name, type, data, threshold, actions } = trigger; const { enabled, name, type, data, threshold, actions, friendly_name } =
trigger;
const embeddingBody: TriggerEmbeddingBody = { type, data, threshold }; const embeddingBody: TriggerEmbeddingBody = { type, data, threshold };
const embeddingUrl = isEdit const embeddingUrl = isEdit
? `/trigger/embedding/${selectedCamera}/${name}` ? `/trigger/embedding/${selectedCamera}/${name}`
@ -162,6 +178,7 @@ export default function TriggerView({
data, data,
threshold, threshold,
actions, actions,
friendly_name,
}, },
}, },
}, },
@ -220,9 +237,21 @@ export default function TriggerView({
data: string, data: string,
threshold: number, threshold: number,
actions: TriggerAction[], actions: TriggerAction[],
friendly_name: string,
) => { ) => {
setUnsavedChanges(true); setUnsavedChanges(true);
saveToConfig({ enabled, name, type, data, threshold, actions }, false); saveToConfig(
{
enabled,
name,
type,
data,
threshold,
actions,
friendly_name,
},
false,
);
}, },
[saveToConfig, setUnsavedChanges], [saveToConfig, setUnsavedChanges],
); );
@ -359,7 +388,7 @@ export default function TriggerView({
// for adding a trigger with event id via explore context menu // for adding a trigger with event id via explore context menu
useSearchEffect("event_id", (eventId: string) => { useSearchEffect("event_id", (eventId: string) => {
if (!config || isLoading) { if (!config || isLoading || !isSemanticSearchEnabled) {
return false; return false;
} }
setShowCreate(true); setShowCreate(true);
@ -386,189 +415,227 @@ export default function TriggerView({
<div className="flex size-full flex-col md:flex-row"> <div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} /> <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="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"> {!isSemanticSearchEnabled ? (
<div className="flex flex-col items-start"> <div className="mb-5 flex flex-row items-center justify-between gap-2">
<Heading as="h3" className="my-2"> <div className="flex flex-col items-start">
{t("triggers.management.title")} <Heading as="h3" className="my-2">
</Heading> {t("triggers.management.title")}
<p className="text-sm text-muted-foreground"> </Heading>
{t("triggers.management.desc", { <p className="mb-5 text-sm text-muted-foreground">
camera: cameraName, {t("triggers.management.desc", {
})} camera: cameraName,
</p> })}
</div> </p>
<Button <Alert variant="destructive">
className="flex items-center gap-2 self-start sm:self-auto" <CiCircleAlert className="size-5" />
aria-label={t("triggers.addTrigger")} <AlertTitle>{t("triggers.semanticSearch.title")}</AlertTitle>
variant="default" <AlertDescription>
onClick={() => { <Trans ns="views/settings">
setSelectedTrigger(null); triggers.semanticSearch.desc
setShowCreate(true); </Trans>
}} <div className="mt-3 flex items-center">
disabled={isLoading} <Link
> to={getLocaleDocUrl("configuration/semantic_search")}
<LuPlus className="size-4" /> target="_blank"
{t("triggers.addTrigger")} rel="noopener noreferrer"
</Button> className="inline"
</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 p-0">
{triggers.length === 0 ? (
<div className="flex h-24 items-center justify-center">
<p className="text-center text-muted-foreground">
{t("triggers.table.noTriggers")}
</p>
</div>
) : (
<div className="space-y-2">
{triggers.map((trigger) => (
<div
key={trigger.name}
id={`trigger-${trigger.name}`}
className="relative flex items-center justify-between rounded-lg border border-border bg-background p-4 transition-all"
> >
<div {t("readTheDocumentation", { ns: "common" })}{" "}
className={cn( <LuExternalLink className="ml-2 inline-flex size-3" />
"trigger-ring pointer-events-none absolute inset-0 z-10 size-full rounded-md outline outline-[3px] -outline-offset-[2.8px] duration-500", </Link>
triggeredTrigger === trigger.name </div>
? "shadow-selected outline-selected" </AlertDescription>
: "outline-transparent duration-500", </Alert>
)}
/>
<div className="min-w-0 flex-1">
<h3
className={cn(
"truncate text-lg font-medium",
!trigger.enabled && "opacity-60",
)}
>
{trigger.name}
</h3>
<div
className={cn(
"mt-1 flex flex-col gap-1 text-sm text-muted-foreground md:flex-row md:items-center md:gap-3",
!trigger.enabled && "opacity-60",
)}
>
<div>
<Badge
variant={
trigger.type === "thumbnail"
? "default"
: "outline"
}
className={
trigger.type === "thumbnail"
? "bg-primary/20 text-primary hover:bg-primary/30"
: ""
}
>
{t(`triggers.type.${trigger.type}`)}
</Badge>
</div>
<Link
to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`}
className={cn(
"text-sm",
!trigger_status?.triggers[trigger.name]
?.triggering_event_id && "pointer-events-none",
)}
>
<div className="flex flex-row items-center">
{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"}
<Tooltip>
<TooltipTrigger>
<LuSearch className="ml-2 size-3.5" />
</TooltipTrigger>
<TooltipContent>
{t("details.item.button.viewInExplore", {
ns: "views/explore",
})}
</TooltipContent>
</Tooltip>
</div>
</Link>
</div>
</div>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => {
setSelectedTrigger(trigger);
setShowCreate(true);
}}
disabled={isLoading}
>
<LuPencil className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("triggers.table.edit")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="destructive"
className="h-8 w-8 p-0 text-white"
onClick={() => {
setSelectedTrigger(trigger);
setShowDelete(true);
}}
disabled={isLoading}
>
<LuTrash className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("triggers.table.deleteTrigger")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
))}
</div>
)}
</div> </div>
</div> </div>
</div> ) : (
<>
<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: cameraName,
})}
</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 p-0">
{triggers.length === 0 ? (
<div className="flex h-24 items-center justify-center">
<p className="text-center text-muted-foreground">
{t("triggers.table.noTriggers")}
</p>
</div>
) : (
<div className="space-y-2">
{triggers.map((trigger) => (
<div
key={trigger.name}
id={`trigger-${trigger.name}`}
className="relative flex items-center justify-between rounded-lg border border-border bg-background p-4 transition-all"
>
<div
className={cn(
"trigger-ring pointer-events-none absolute inset-0 z-10 size-full rounded-md outline outline-[3px] -outline-offset-[2.8px] duration-500",
triggeredTrigger === trigger.name
? "shadow-selected outline-selected"
: "outline-transparent duration-500",
)}
/>
<div className="min-w-0 flex-1">
<h3
className={cn(
"truncate text-lg font-medium",
!trigger.enabled && "opacity-60",
)}
>
{trigger.friendly_name || trigger.name}
</h3>
<div
className={cn(
"mt-1 flex flex-col gap-1 text-sm text-muted-foreground md:flex-row md:items-center md:gap-3",
!trigger.enabled && "opacity-60",
)}
>
<div>
<Badge
variant={
trigger.type === "thumbnail"
? "default"
: "outline"
}
className={
trigger.type === "thumbnail"
? "bg-primary/20 text-primary hover:bg-primary/30"
: ""
}
>
{t(`triggers.type.${trigger.type}`)}
</Badge>
</div>
<Link
to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`}
className={cn(
"text-sm",
!trigger_status?.triggers[trigger.name]
?.triggering_event_id &&
"pointer-events-none",
)}
>
<div className="flex flex-row items-center">
{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"}
<Tooltip>
<TooltipTrigger>
<LuSearch className="ml-2 size-3.5" />
</TooltipTrigger>
<TooltipContent>
{t("details.item.button.viewInExplore", {
ns: "views/explore",
})}
</TooltipContent>
</Tooltip>
</div>
</Link>
</div>
</div>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => {
setSelectedTrigger(trigger);
setShowCreate(true);
}}
disabled={isLoading}
>
<LuPencil className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("triggers.table.edit")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="destructive"
className="h-8 w-8 p-0 text-white"
onClick={() => {
setSelectedTrigger(trigger);
setShowDelete(true);
}}
disabled={isLoading}
>
<LuTrash className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("triggers.table.deleteTrigger")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</>
)}
</div> </div>
<CreateTriggerDialog <CreateTriggerDialog
show={showCreate} show={showCreate}