Triggers tweaks (#20339)

* backend

* frontend

* use correct camera name param

* i18n

* change log message to debug level

* docs tweaks
This commit is contained in:
Josh Hawkins
2025-10-03 06:36:14 -06:00
committed by GitHub
parent 2d45ea271e
commit d818dbb6ba
9 changed files with 313 additions and 190 deletions
+253 -186
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
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 {
@@ -12,7 +13,13 @@ import {
TooltipProvider,
TooltipTrigger,
} 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 CreateTriggerDialog from "@/components/overlay/CreateTriggerDialog";
import DeleteTriggerDialog from "@/components/overlay/DeleteTriggerDialog";
@@ -24,6 +31,8 @@ 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";
type ConfigSetBody = {
requires_restart: number;
@@ -39,6 +48,7 @@ type ConfigSetBody = {
data: string;
threshold: number;
actions: string[];
friendly_name?: string;
}
| "";
};
@@ -80,6 +90,10 @@ export default function TriggerView({
const [isLoading, setIsLoading] = useState(false);
const cameraName = useCameraFriendlyName(selectedCamera);
const isSemanticSearchEnabled = config?.semantic_search?.enabled ?? false;
const { getLocaleDocUrl } = useDocDomain();
const triggers = useMemo(() => {
if (
!config ||
@@ -93,6 +107,7 @@ export default function TriggerView({
).map(([name, trigger]) => ({
enabled: trigger.enabled,
name,
friendly_name: trigger.friendly_name,
type: trigger.type,
data: trigger.data,
threshold: trigger.threshold,
@@ -139,11 +154,12 @@ export default function TriggerView({
const saveToConfig = useCallback(
(trigger: Trigger, isEdit: boolean) => {
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 embeddingUrl = isEdit
? `/trigger/embedding/${selectedCamera}/${name}`
: `/trigger/embedding?camera=${selectedCamera}&name=${name}`;
: `/trigger/embedding?camera_name=${selectedCamera}&name=${name}`;
const embeddingMethod = isEdit ? axios.put : axios.post;
embeddingMethod(embeddingUrl, embeddingBody)
@@ -162,6 +178,7 @@ export default function TriggerView({
data,
threshold,
actions,
friendly_name,
},
},
},
@@ -220,9 +237,21 @@ export default function TriggerView({
data: string,
threshold: number,
actions: TriggerAction[],
friendly_name: string,
) => {
setUnsavedChanges(true);
saveToConfig({ enabled, name, type, data, threshold, actions }, false);
saveToConfig(
{
enabled,
name,
type,
data,
threshold,
actions,
friendly_name,
},
false,
);
},
[saveToConfig, setUnsavedChanges],
);
@@ -359,7 +388,7 @@ export default function TriggerView({
// for adding a trigger with event id via explore context menu
useSearchEffect("event_id", (eventId: string) => {
if (!config || isLoading) {
if (!config || isLoading || !isSemanticSearchEnabled) {
return false;
}
setShowCreate(true);
@@ -386,189 +415,227 @@ export default function TriggerView({
<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: 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"
{!isSemanticSearchEnabled ? (
<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="mb-5 text-sm text-muted-foreground">
{t("triggers.management.desc", {
camera: cameraName,
})}
</p>
<Alert variant="destructive">
<CiCircleAlert className="size-5" />
<AlertTitle>{t("triggers.semanticSearch.title")}</AlertTitle>
<AlertDescription>
<Trans ns="views/settings">
triggers.semanticSearch.desc
</Trans>
<div className="mt-3 flex items-center">
<Link
to={getLocaleDocUrl("configuration/semantic_search")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
<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.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>
)}
{t("readTheDocumentation", { ns: "common" })}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</AlertDescription>
</Alert>
</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>
<CreateTriggerDialog
show={showCreate}