* Add reindex progress to mobile bottom bar status alert

* move menu to new component

* actions component in search footer thumbnail

* context menu for explore summary thumbnail images

* readd top_score to search query for old events
This commit is contained in:
Josh Hawkins 2024-10-22 09:01:01 -05:00 committed by GitHub
parent 40c6fda19d
commit 828fdbfd2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 346 additions and 232 deletions

View File

@ -394,6 +394,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
Event.end_time, Event.end_time,
Event.has_clip, Event.has_clip,
Event.has_snapshot, Event.has_snapshot,
Event.top_score,
Event.data, Event.data,
Event.plus_id, Event.plus_id,
ReviewSegment.thumb_path, ReviewSegment.thumb_path,

View File

@ -1,38 +1,10 @@
import { useCallback, useState } from "react";
import TimeAgo from "../dynamic/TimeAgo"; import TimeAgo from "../dynamic/TimeAgo";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import ActivityIndicator from "../indicators/activity-indicator";
import { SearchResult } from "@/types/search"; import { SearchResult } from "@/types/search";
import { import ActivityIndicator from "../indicators/activity-indicator";
DropdownMenu, import SearchResultActions from "../menu/SearchResultActions";
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import { LuCamera, LuDownload, LuMoreVertical, LuTrash2 } from "react-icons/lu";
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
import { FrigatePlusDialog } from "../overlay/dialog/FrigatePlusDialog";
import { Event } from "@/types/event";
import { FaArrowsRotate } from "react-icons/fa6";
import { baseUrl } from "@/api/baseUrl";
import axios from "axios";
import { toast } from "sonner";
import { MdImageSearch } from "react-icons/md";
import { isMobileOnly } from "react-device-detect";
import { buttonVariants } from "../ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type SearchThumbnailProps = { type SearchThumbnailProps = {
@ -52,31 +24,7 @@ export default function SearchThumbnailFooter({
}: SearchThumbnailProps) { }: SearchThumbnailProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
// interactions
const [showFrigatePlus, setShowFrigatePlus] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const handleDelete = useCallback(() => {
axios
.delete(`events/${searchResult.id}`)
.then((resp) => {
if (resp.status == 200) {
toast.success("Tracked object deleted successfully.", {
position: "top-center",
});
refreshResults();
}
})
.catch(() => {
toast.error("Failed to delete tracked object.", {
position: "top-center",
});
});
}, [searchResult, refreshResults]);
// date // date
const formattedDate = useFormattedTimestamp( const formattedDate = useFormattedTimestamp(
searchResult.start_time, searchResult.start_time,
config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p",
@ -84,39 +32,6 @@ export default function SearchThumbnailFooter({
); );
return ( return (
<>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete this tracked object?
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={handleDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<FrigatePlusDialog
upload={
showFrigatePlus ? (searchResult as unknown as Event) : undefined
}
onClose={() => setShowFrigatePlus(false)}
onEventUploaded={() => {
searchResult.plus_id = "submitted";
}}
/>
<div <div
className={cn( className={cn(
"flex w-full flex-row items-center justify-between", "flex w-full flex-row items-center justify-between",
@ -135,95 +50,13 @@ export default function SearchThumbnailFooter({
{formattedDate} {formattedDate}
</div> </div>
<div className="flex flex-row items-center justify-end gap-6 md:gap-4"> <div className="flex flex-row items-center justify-end gap-6 md:gap-4">
{!isMobileOnly && <SearchResultActions
config?.plus?.enabled && searchResult={searchResult}
searchResult.has_snapshot && findSimilar={findSimilar}
searchResult.end_time && refreshResults={refreshResults}
!searchResult.plus_id && ( showObjectLifecycle={showObjectLifecycle}
<Tooltip>
<TooltipTrigger>
<FrigatePlusIcon
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
onClick={() => setShowFrigatePlus(true)}
/> />
</TooltipTrigger>
<TooltipContent>Submit to Frigate+</TooltipContent>
</Tooltip>
)}
{config?.semantic_search?.enabled && (
<Tooltip>
<TooltipTrigger>
<MdImageSearch
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
onClick={findSimilar}
/>
</TooltipTrigger>
<TooltipContent>Find similar</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger>
<LuMoreVertical className="size-5 cursor-pointer text-primary-variant hover:text-primary" />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"}>
{searchResult.has_clip && (
<DropdownMenuItem>
<a
className="justify_start flex items-center"
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
download={`${searchResult.camera}_${searchResult.label}.mp4`}
>
<LuDownload className="mr-2 size-4" />
<span>Download video</span>
</a>
</DropdownMenuItem>
)}
{searchResult.has_snapshot && (
<DropdownMenuItem>
<a
className="justify_start flex items-center"
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
download={`${searchResult.camera}_${searchResult.label}.jpg`}
>
<LuCamera className="mr-2 size-4" />
<span>Download snapshot</span>
</a>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer"
onClick={showObjectLifecycle}
>
<FaArrowsRotate className="mr-2 size-4" />
<span>View object lifecycle</span>
</DropdownMenuItem>
{isMobileOnly &&
config?.plus?.enabled &&
searchResult.has_snapshot &&
searchResult.end_time &&
!searchResult.plus_id && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => setShowFrigatePlus(true)}
>
<FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" />
<span>Submit to Frigate+</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer"
onClick={() => setDeleteDialogOpen(true)}
>
<LuTrash2 className="mr-2 size-4" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
</>
); );
} }

View File

@ -0,0 +1,218 @@
import { useState, ReactNode } 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 { LuCamera, LuDownload, LuMoreVertical, LuTrash2 } from "react-icons/lu";
import { FaArrowsRotate } from "react-icons/fa6";
import { MdImageSearch } from "react-icons/md";
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
import { isMobileOnly } from "react-device-detect";
import { buttonVariants } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
import useSWR from "swr";
import { Event } from "@/types/event";
type SearchResultActionsProps = {
searchResult: SearchResult;
findSimilar: () => void;
refreshResults: () => void;
showObjectLifecycle: () => void;
isContextMenu?: boolean;
children?: ReactNode;
};
export default function SearchResultActions({
searchResult,
findSimilar,
refreshResults,
showObjectLifecycle,
isContextMenu = false,
children,
}: SearchResultActionsProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const [showFrigatePlus, setShowFrigatePlus] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const handleDelete = () => {
axios
.delete(`events/${searchResult.id}`)
.then((resp) => {
if (resp.status == 200) {
toast.success("Tracked object deleted successfully.", {
position: "top-center",
});
refreshResults();
}
})
.catch(() => {
toast.error("Failed to delete tracked object.", {
position: "top-center",
});
});
};
const MenuItem = isContextMenu ? ContextMenuItem : DropdownMenuItem;
const menuItems = (
<>
{searchResult.has_clip && (
<MenuItem>
<a
className="flex items-center"
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
download={`${searchResult.camera}_${searchResult.label}.mp4`}
>
<LuDownload className="mr-2 size-4" />
<span>Download video</span>
</a>
</MenuItem>
)}
{searchResult.has_snapshot && (
<MenuItem>
<a
className="flex items-center"
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
download={`${searchResult.camera}_${searchResult.label}.jpg`}
>
<LuCamera className="mr-2 size-4" />
<span>Download snapshot</span>
</a>
</MenuItem>
)}
<MenuItem onClick={showObjectLifecycle}>
<FaArrowsRotate className="mr-2 size-4" />
<span>View object lifecycle</span>
</MenuItem>
{config?.semantic_search?.enabled && isContextMenu && (
<MenuItem onClick={findSimilar}>
<MdImageSearch className="mr-2 size-4" />
<span>Find similar</span>
</MenuItem>
)}
{isMobileOnly &&
config?.plus?.enabled &&
searchResult.has_snapshot &&
searchResult.end_time &&
!searchResult.plus_id && (
<MenuItem onClick={() => setShowFrigatePlus(true)}>
<FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" />
<span>Submit to Frigate+</span>
</MenuItem>
)}
<MenuItem onClick={() => setDeleteDialogOpen(true)}>
<LuTrash2 className="mr-2 size-4" />
<span>Delete</span>
</MenuItem>
</>
);
return (
<>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete this tracked object?
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={handleDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<FrigatePlusDialog
upload={
showFrigatePlus ? (searchResult as unknown as Event) : undefined
}
onClose={() => setShowFrigatePlus(false)}
onEventUploaded={() => {
searchResult.plus_id = "submitted";
}}
/>
{isContextMenu ? (
<ContextMenu>
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>{menuItems}</ContextMenuContent>
</ContextMenu>
) : (
<>
{config?.semantic_search?.enabled && (
<Tooltip>
<TooltipTrigger>
<MdImageSearch
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
onClick={findSimilar}
/>
</TooltipTrigger>
<TooltipContent>Find similar</TooltipContent>
</Tooltip>
)}
{!isMobileOnly &&
config?.plus?.enabled &&
searchResult.has_snapshot &&
searchResult.end_time &&
!searchResult.plus_id && (
<Tooltip>
<TooltipTrigger>
<FrigatePlusIcon
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
onClick={() => setShowFrigatePlus(true)}
/>
</TooltipTrigger>
<TooltipContent>Submit to Frigate+</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger>
<LuMoreVertical className="size-5 cursor-pointer text-primary-variant hover:text-primary" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">{menuItems}</DropdownMenuContent>
</DropdownMenu>
</>
)}
</>
);
}

View File

@ -3,7 +3,7 @@ import { IoIosWarning } from "react-icons/io";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateStats } from "@/types/stats"; import { FrigateStats } from "@/types/stats";
import { useFrigateStats } from "@/api/ws"; import { useEmbeddingsReindexProgress, useFrigateStats } from "@/api/ws";
import { useContext, useEffect, useMemo } from "react"; import { useContext, useEffect, useMemo } from "react";
import useStats from "@/hooks/use-stats"; import useStats from "@/hooks/use-stats";
import GeneralSettings from "../menu/GeneralSettings"; import GeneralSettings from "../menu/GeneralSettings";
@ -74,6 +74,23 @@ function StatusAlertNav({ className }: StatusAlertNavProps) {
}); });
}, [potentialProblems, addMessage, clearMessages]); }, [potentialProblems, addMessage, clearMessages]);
const { payload: reindexState } = useEmbeddingsReindexProgress();
useEffect(() => {
if (reindexState) {
if (reindexState.status == "indexing") {
clearMessages("embeddings-reindex");
addMessage(
"embeddings-reindex",
`Reindexing embeddings (${Math.floor((reindexState.processed_objects / reindexState.total_objects) * 100)}% complete)`,
);
}
if (reindexState.status === "completed") {
clearMessages("embeddings-reindex");
}
}
}, [reindexState, addMessage, clearMessages]);
if (!messages || Object.keys(messages).length === 0) { if (!messages || Object.keys(messages).length === 0) {
return; return;
} }

View File

@ -18,15 +18,22 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useEventUpdate } from "@/api/ws"; import { useEventUpdate } from "@/api/ws";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import TimeAgo from "@/components/dynamic/TimeAgo"; import TimeAgo from "@/components/dynamic/TimeAgo";
import SearchResultActions from "@/components/menu/SearchResultActions";
import { SearchTab } from "@/components/overlay/detail/SearchDetailDialog";
import { FrigateConfig } from "@/types/frigateConfig";
type ExploreViewProps = { type ExploreViewProps = {
searchDetail: SearchResult | undefined; searchDetail: SearchResult | undefined;
setSearchDetail: (search: SearchResult | undefined) => void; setSearchDetail: (search: SearchResult | undefined) => void;
setSimilaritySearch: (search: SearchResult) => void;
onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void;
}; };
export default function ExploreView({ export default function ExploreView({
searchDetail, searchDetail,
setSearchDetail, setSearchDetail,
setSimilaritySearch,
onSelectSearch,
}: ExploreViewProps) { }: ExploreViewProps) {
// title // title
@ -102,6 +109,9 @@ export default function ExploreView({
isValidating={isValidating} isValidating={isValidating}
objectType={label} objectType={label}
setSearchDetail={setSearchDetail} setSearchDetail={setSearchDetail}
mutate={mutate}
setSimilaritySearch={setSimilaritySearch}
onSelectSearch={onSelectSearch}
/> />
))} ))}
</div> </div>
@ -113,6 +123,9 @@ type ThumbnailRowType = {
searchResults?: SearchResult[]; searchResults?: SearchResult[];
isValidating: boolean; isValidating: boolean;
setSearchDetail: (search: SearchResult | undefined) => void; setSearchDetail: (search: SearchResult | undefined) => void;
mutate: () => void;
setSimilaritySearch: (search: SearchResult) => void;
onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void;
}; };
function ThumbnailRow({ function ThumbnailRow({
@ -120,6 +133,9 @@ function ThumbnailRow({
searchResults, searchResults,
isValidating, isValidating,
setSearchDetail, setSearchDetail,
mutate,
setSimilaritySearch,
onSelectSearch,
}: ThumbnailRowType) { }: ThumbnailRowType) {
const navigate = useNavigate(); const navigate = useNavigate();
@ -155,6 +171,9 @@ function ThumbnailRow({
<ExploreThumbnailImage <ExploreThumbnailImage
event={event} event={event}
setSearchDetail={setSearchDetail} setSearchDetail={setSearchDetail}
mutate={mutate}
setSimilaritySearch={setSimilaritySearch}
onSelectSearch={onSelectSearch}
/> />
</div> </div>
))} ))}
@ -184,25 +203,49 @@ function ThumbnailRow({
type ExploreThumbnailImageProps = { type ExploreThumbnailImageProps = {
event: SearchResult; event: SearchResult;
setSearchDetail: (search: SearchResult | undefined) => void; setSearchDetail: (search: SearchResult | undefined) => void;
mutate: () => void;
setSimilaritySearch: (search: SearchResult) => void;
onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void;
}; };
function ExploreThumbnailImage({ function ExploreThumbnailImage({
event, event,
setSearchDetail, setSearchDetail,
mutate,
setSimilaritySearch,
onSelectSearch,
}: ExploreThumbnailImageProps) { }: ExploreThumbnailImageProps) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
const handleFindSimilar = () => {
if (config?.semantic_search.enabled) {
setSimilaritySearch(event);
}
};
const handleShowObjectLifecycle = () => {
onSelectSearch(event, 0, "object lifecycle");
};
return ( return (
<> <SearchResultActions
searchResult={event}
findSimilar={handleFindSimilar}
refreshResults={mutate}
showObjectLifecycle={handleShowObjectLifecycle}
isContextMenu={true}
>
<div className="relative size-full">
<ImageLoadingIndicator <ImageLoadingIndicator
className="absolute inset-0" className="absolute inset-0"
imgLoaded={imgLoaded} imgLoaded={imgLoaded}
/> />
<img <img
ref={imgRef} ref={imgRef}
className={cn( className={cn(
"absolute h-full w-full cursor-pointer rounded-lg object-cover transition-all duration-300 ease-in-out lg:rounded-2xl", "absolute size-full cursor-pointer rounded-lg object-cover transition-all duration-300 ease-in-out lg:rounded-2xl",
!imgLoaded && "invisible",
)} )}
style={ style={
isIOS isIOS
@ -216,9 +259,8 @@ function ExploreThumbnailImage({
draggable={false} draggable={false}
src={`${apiHost}api/events/${event.id}/thumbnail.jpg`} src={`${apiHost}api/events/${event.id}/thumbnail.jpg`}
onClick={() => setSearchDetail(event)} onClick={() => setSearchDetail(event)}
onLoad={() => { onLoad={onImgLoad}
onImgLoad(); alt={`${event.label} thumbnail`}
}}
/> />
{isDesktop && ( {isDesktop && (
<div className="absolute bottom-1 right-1 z-10 rounded-lg bg-black/50 px-2 py-1 text-xs text-white"> <div className="absolute bottom-1 right-1 z-10 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
@ -231,7 +273,8 @@ function ExploreThumbnailImage({
)} )}
</div> </div>
)} )}
</> </div>
</SearchResultActions>
); );
} }

View File

@ -489,6 +489,8 @@ export default function SearchView({
<ExploreView <ExploreView
searchDetail={searchDetail} searchDetail={searchDetail}
setSearchDetail={setSearchDetail} setSearchDetail={setSearchDetail}
setSimilaritySearch={setSimilaritySearch}
onSelectSearch={onSelectSearch}
/> />
</div> </div>
)} )}