add triggers from explore

This commit is contained in:
Josh Hawkins 2025-07-02 17:19:02 -05:00
parent aca9e15790
commit efe5864ebc
9 changed files with 84 additions and 22 deletions

View File

@ -175,6 +175,10 @@
"label": "Find similar", "label": "Find similar",
"aria": "Find similar tracked objects" "aria": "Find similar tracked objects"
}, },
"addTrigger": {
"label": "Add trigger",
"aria": "Add a trigger for this tracked object"
},
"audioTranscription": { "audioTranscription": {
"label": "Transcribe", "label": "Transcribe",
"aria": "Request audio transcription" "aria": "Request audio transcription"

View File

@ -673,15 +673,15 @@
"dialog": { "dialog": {
"createTrigger": { "createTrigger": {
"title": "Create Trigger", "title": "Create Trigger",
"desc": "Create a new trigger for camera '{{camera}}'." "desc": "Create a trigger for camera {{camera}}"
}, },
"editTrigger": { "editTrigger": {
"title": "Edit Trigger", "title": "Edit Trigger",
"desc": "Edit the settings for an existing trigger on camera '{{camera}}'." "desc": "Edit the settings for trigger on camera {{camera}}"
}, },
"deleteTrigger": { "deleteTrigger": {
"title": "Delete Trigger", "title": "Delete Trigger",
"desc": "Are you sure you want to delete the trigger <strong>{{triggerName}}</strong> from camera '{{camera}}'? This action cannot be undone." "desc": "Are you sure you want to delete the trigger <strong>{{triggerName}}</strong> from camera {{camera}}? This action cannot be undone."
}, },
"form": { "form": {
"name": { "name": {
@ -725,9 +725,9 @@
}, },
"toast": { "toast": {
"success": { "success": {
"createTrigger": "Trigger '{{name}}' created successfully.", "createTrigger": "Trigger {{name}} created successfully.",
"updateTrigger": "Trigger '{{name}}' updated successfully.", "updateTrigger": "Trigger {{name}} updated successfully.",
"deleteTrigger": "Trigger '{{name}}' deleted successfully." "deleteTrigger": "Trigger {{name}} deleted successfully."
}, },
"error": { "error": {
"createTriggerFailed": "Failed to create trigger: {{errorMessage}}", "createTriggerFailed": "Failed to create trigger: {{errorMessage}}",

View File

@ -15,6 +15,7 @@ type SearchThumbnailProps = {
refreshResults: () => void; refreshResults: () => void;
showObjectLifecycle: () => void; showObjectLifecycle: () => void;
showSnapshot: () => void; showSnapshot: () => void;
addTrigger: () => void;
}; };
export default function SearchThumbnailFooter({ export default function SearchThumbnailFooter({
@ -24,6 +25,7 @@ export default function SearchThumbnailFooter({
refreshResults, refreshResults,
showObjectLifecycle, showObjectLifecycle,
showSnapshot, showSnapshot,
addTrigger,
}: SearchThumbnailProps) { }: SearchThumbnailProps) {
const { t } = useTranslation(["views/search"]); const { t } = useTranslation(["views/search"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -61,6 +63,7 @@ export default function SearchThumbnailFooter({
refreshResults={refreshResults} refreshResults={refreshResults}
showObjectLifecycle={showObjectLifecycle} showObjectLifecycle={showObjectLifecycle}
showSnapshot={showSnapshot} showSnapshot={showSnapshot}
addTrigger={addTrigger}
/> />
</div> </div>
</div> </div>

View File

@ -41,6 +41,7 @@ import {
import useSWR from "swr"; import useSWR from "swr";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { BsFillLightningFill } from "react-icons/bs";
type SearchResultActionsProps = { type SearchResultActionsProps = {
searchResult: SearchResult; searchResult: SearchResult;
@ -48,6 +49,7 @@ type SearchResultActionsProps = {
refreshResults: () => void; refreshResults: () => void;
showObjectLifecycle: () => void; showObjectLifecycle: () => void;
showSnapshot: () => void; showSnapshot: () => void;
addTrigger: () => void;
isContextMenu?: boolean; isContextMenu?: boolean;
children?: ReactNode; children?: ReactNode;
}; };
@ -58,6 +60,7 @@ export default function SearchResultActions({
refreshResults, refreshResults,
showObjectLifecycle, showObjectLifecycle,
showSnapshot, showSnapshot,
addTrigger,
isContextMenu = false, isContextMenu = false,
children, children,
}: SearchResultActionsProps) { }: SearchResultActionsProps) {
@ -138,6 +141,16 @@ export default function SearchResultActions({
<span>{t("itemMenu.findSimilar.label")}</span> <span>{t("itemMenu.findSimilar.label")}</span>
</MenuItem> </MenuItem>
)} )}
{config?.semantic_search?.enabled &&
searchResult.data.type == "object" && (
<MenuItem
aria-label={t("itemMenu.addTrigger.aria")}
onClick={addTrigger}
>
<BsFillLightningFill className="mr-2 size-4" />
<span>{t("itemMenu.addTrigger.label")}</span>
</MenuItem>
)}
{isMobileOnly && {isMobileOnly &&
config?.plus?.enabled && config?.plus?.enabled &&
searchResult.has_snapshot && searchResult.has_snapshot &&

View File

@ -95,9 +95,7 @@ export default function CreateTriggerDialog({
.number() .number()
.min(0, t("triggers.dialog.form.threshold.error.min")) .min(0, t("triggers.dialog.form.threshold.error.min"))
.max(1, t("triggers.dialog.form.threshold.error.max")), .max(1, t("triggers.dialog.form.threshold.error.max")),
actions: z actions: z.array(z.enum(["alert", "notification"])),
.array(z.enum(["alert", "notification"]))
.min(1, t("triggers.dialog.form.actions.error.min")),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
@ -109,7 +107,7 @@ export default function CreateTriggerDialog({
type: trigger?.type ?? "description", type: trigger?.type ?? "description",
data: trigger?.data ?? "", data: trigger?.data ?? "",
threshold: trigger?.threshold ?? 0.5, threshold: trigger?.threshold ?? 0.5,
actions: trigger?.actions ?? ["alert"], actions: trigger?.actions ?? [],
}, },
}); });
@ -138,17 +136,22 @@ export default function CreateTriggerDialog({
type: "description", type: "description",
data: "", data: "",
threshold: 0.5, threshold: 0.5,
actions: ["alert"], actions: [],
}); });
} else if (trigger) { } else if (trigger) {
form.reset({ form.reset(
enabled: trigger.enabled, {
name: trigger.name, enabled: trigger.enabled,
type: trigger.type, name: trigger.name,
data: trigger.data, type: trigger.type,
threshold: trigger.threshold, data: trigger.data,
actions: trigger.actions, threshold: trigger.threshold,
}); actions: trigger.actions,
},
{ keepDirty: false, keepTouched: false }, // Reset validation state
);
// Trigger validation to ensure isValid updates
// form.trigger();
} }
}, [show, trigger, form]); }, [show, trigger, form]);

View File

@ -173,7 +173,7 @@ export default function Settings() {
} }
} }
// don't clear url params if we're creating a new object mask // don't clear url params if we're creating a new object mask
return !searchParams.has("object_mask"); return !(searchParams.has("object_mask") || searchParams.has("event_id"));
}); });
useSearchEffect("camera", (camera: string) => { useSearchEffect("camera", (camera: string) => {
@ -181,8 +181,8 @@ export default function Settings() {
if (cameraNames.includes(camera)) { if (cameraNames.includes(camera)) {
setSelectedCamera(camera); setSelectedCamera(camera);
} }
// don't clear url params if we're creating a new object mask // don't clear url params if we're creating a new object mask or trigger
return !searchParams.has("object_mask"); return !(searchParams.has("object_mask") || searchParams.has("event_id"));
}); });
useEffect(() => { useEffect(() => {

View File

@ -218,6 +218,7 @@ function ExploreThumbnailImage({
const apiHost = useApiHost(); const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
const navigate = useNavigate();
const handleFindSimilar = () => { const handleFindSimilar = () => {
if (config?.semantic_search.enabled) { if (config?.semantic_search.enabled) {
@ -233,6 +234,12 @@ function ExploreThumbnailImage({
onSelectSearch(event, false, "snapshot"); onSelectSearch(event, false, "snapshot");
}; };
const handleAddTrigger = () => {
navigate(
`/settings?page=triggers&camera=${event.camera}&event_id=${event.id}`,
);
};
return ( return (
<SearchResultActions <SearchResultActions
searchResult={event} searchResult={event}
@ -240,6 +247,7 @@ function ExploreThumbnailImage({
refreshResults={mutate} refreshResults={mutate}
showObjectLifecycle={handleShowObjectLifecycle} showObjectLifecycle={handleShowObjectLifecycle}
showSnapshot={handleShowSnapshot} showSnapshot={handleShowSnapshot}
addTrigger={handleAddTrigger}
isContextMenu={true} isContextMenu={true}
> >
<div className="relative size-full"> <div className="relative size-full">

View File

@ -32,6 +32,7 @@ import Chip from "@/components/indicators/Chip";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import SearchActionGroup from "@/components/filter/SearchActionGroup"; import SearchActionGroup from "@/components/filter/SearchActionGroup";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
type SearchViewProps = { type SearchViewProps = {
search: string; search: string;
@ -76,6 +77,7 @@ export default function SearchView({
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
const navigate = useNavigate();
// grid // grid
@ -648,6 +650,16 @@ export default function SearchView({
showSnapshot={() => showSnapshot={() =>
onSelectSearch(value, false, "snapshot") onSelectSearch(value, false, "snapshot")
} }
addTrigger={() => {
if (
config?.semantic_search.enabled &&
value.data.type == "object"
) {
navigate(
`/settings?page=triggers&camera=${value.camera}&event_id=${value.id}`,
);
}
}}
/> />
</div> </div>
</div> </div>

View File

@ -26,6 +26,7 @@ import CreateTriggerDialog from "@/components/overlay/CreateTriggerDialog";
import DeleteTriggerDialog from "@/components/overlay/DeleteTriggerDialog"; import DeleteTriggerDialog from "@/components/overlay/DeleteTriggerDialog";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { Trigger, TriggerAction, TriggerType } from "@/types/trigger"; import { Trigger, TriggerAction, TriggerType } from "@/types/trigger";
import { useSearchEffect } from "@/hooks/use-overlay-state";
type ConfigSetBody = { type ConfigSetBody = {
requires_restart: number; requires_restart: number;
@ -296,6 +297,24 @@ export default function TriggerView({
} }
}, [selectedCamera, setUnsavedChanges]); }, [selectedCamera, setUnsavedChanges]);
// for adding a trigger with event id via explore context menu
useSearchEffect("event_id", (eventId: string) => {
if (!config || isLoading) {
return false;
}
setShowCreate(true);
setSelectedTrigger({
enabled: true,
name: "",
type: "thumbnail",
data: eventId,
threshold: 0.5,
actions: [],
});
return true;
});
if (!config || !selectedCamera) { if (!config || !selectedCamera) {
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">