diff --git a/web/public/locales/en/views/explore.json b/web/public/locales/en/views/explore.json index 53b04e6c4..661a9a5e9 100644 --- a/web/public/locales/en/views/explore.json +++ b/web/public/locales/en/views/explore.json @@ -216,6 +216,10 @@ }, "hideObjectDetails": { "label": "Hide object path" + }, + "debugReplay": { + "label": "Debug replay", + "aria": "View this tracked object in the debug replay view" } }, "dialog": { diff --git a/web/src/components/menu/SearchResultActions.tsx b/web/src/components/menu/SearchResultActions.tsx index 2313b5a03..8b6c6dcb3 100644 --- a/web/src/components/menu/SearchResultActions.tsx +++ b/web/src/components/menu/SearchResultActions.tsx @@ -1,11 +1,11 @@ -import { useState, ReactNode } from "react"; +import { useState, ReactNode, useCallback } from "react"; import { SearchResult } from "@/types/search"; import { FrigateConfig } from "@/types/frigateConfig"; import { baseUrl } from "@/api/baseUrl"; import { toast } from "sonner"; import axios from "axios"; import { FiMoreVertical } from "react-icons/fi"; -import { buttonVariants } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { ContextMenu, ContextMenuContent, @@ -32,6 +32,7 @@ import useSWR from "swr"; import { Trans, useTranslation } from "react-i18next"; import BlurredIconButton from "../button/BlurredIconButton"; import { useIsAdmin } from "@/hooks/use-is-admin"; +import { useNavigate } from "react-router-dom"; type SearchResultActionsProps = { searchResult: SearchResult; @@ -52,8 +53,10 @@ export default function SearchResultActions({ isContextMenu = false, children, }: SearchResultActionsProps) { - const { t } = useTranslation(["views/explore"]); + const { t } = useTranslation(["views/explore", "views/replay", "common"]); const isAdmin = useIsAdmin(); + const navigate = useNavigate(); + const [isStarting, setIsStarting] = useState(false); const { data: config } = useSWR("config"); @@ -84,6 +87,59 @@ export default function SearchResultActions({ }); }; + const handleDebugReplay = useCallback( + (event: SearchResult) => { + setIsStarting(true); + + axios + .post("debug_replay/start", { + camera: event.camera, + start_time: event.start_time, + end_time: event.end_time, + }) + .then((response) => { + if (response.status === 200) { + toast.success(t("dialog.toast.success", { ns: "views/replay" }), { + position: "top-center", + }); + navigate("/replay"); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + + if (error.response?.status === 409) { + toast.error( + t("dialog.toast.alreadyActive", { ns: "views/replay" }), + { + position: "top-center", + closeButton: true, + dismissible: false, + action: ( + + + + ), + }, + ); + } else { + toast.error(t("dialog.toast.error", { error: errorMessage }), { + position: "top-center", + }); + } + }) + .finally(() => { + setIsStarting(false); + }); + }, + [navigate, t], + ); + const MenuItem = isContextMenu ? ContextMenuItem : DropdownMenuItem; const menuItems = ( @@ -149,6 +205,20 @@ export default function SearchResultActions({ {t("itemMenu.addTrigger.label")} )} + {searchResult.has_clip && ( + { + handleDebugReplay(searchResult); + }} + > + {isStarting + ? t("dialog.starting", { ns: "views/replay" }) + : t("itemMenu.debugReplay.label")} + + )} {isAdmin && ( void; @@ -27,7 +27,7 @@ export default function ActionsDropdown({ aria-label={t("menu.actions", { ns: "common" })} size="sm" > - +
{t("menu.actions", { ns: "common" })}
diff --git a/web/src/components/timeline/EventMenu.tsx b/web/src/components/timeline/EventMenu.tsx index e6ad8eba4..98c514945 100644 --- a/web/src/components/timeline/EventMenu.tsx +++ b/web/src/components/timeline/EventMenu.tsx @@ -12,8 +12,11 @@ import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Event } from "@/types/event"; import { FrigateConfig } from "@/types/frigateConfig"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { useIsAdmin } from "@/hooks/use-is-admin"; +import axios from "axios"; +import { toast } from "sonner"; +import { Button } from "../ui/button"; type EventMenuProps = { event: Event; @@ -34,9 +37,10 @@ export default function EventMenu({ }: EventMenuProps) { const apiHost = useApiHost(); const navigate = useNavigate(); - const { t } = useTranslation("views/explore"); + const { t } = useTranslation(["views/explore", "views/replay"]); const [isOpen, setIsOpen] = useState(false); const isAdmin = useIsAdmin(); + const [isStarting, setIsStarting] = useState(false); const handleObjectSelect = () => { if (isSelected) { @@ -46,6 +50,59 @@ export default function EventMenu({ } }; + const handleDebugReplay = useCallback( + (event: Event) => { + setIsStarting(true); + + axios + .post("debug_replay/start", { + camera: event.camera, + start_time: event.start_time, + end_time: event.end_time, + }) + .then((response) => { + if (response.status === 200) { + toast.success(t("dialog.toast.success", { ns: "views/replay" }), { + position: "top-center", + }); + navigate("/replay"); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + + if (error.response?.status === 409) { + toast.error( + t("dialog.toast.alreadyActive", { ns: "views/replay" }), + { + position: "top-center", + closeButton: true, + dismissible: false, + action: ( + + + + ), + }, + ); + } else { + toast.error(t("dialog.toast.error", { error: errorMessage }), { + position: "top-center", + }); + } + }) + .finally(() => { + setIsStarting(false); + }); + }, + [navigate, t], + ); + return ( <> @@ -117,6 +174,19 @@ export default function EventMenu({ {t("itemMenu.findSimilar.label")} )} + {event.has_clip && ( + { + handleDebugReplay(event); + }} + > + {isStarting + ? t("dialog.starting", { ns: "views/replay" }) + : t("itemMenu.debugReplay.label")} + + )}