explore view with new api endpoint

This commit is contained in:
Josh Hawkins 2024-09-11 06:42:49 -05:00
parent 52c8386b52
commit e5e074ab84
6 changed files with 218 additions and 373 deletions

View File

@ -251,6 +251,61 @@ def events():
return jsonify(list(events)) return jsonify(list(events))
@EventBp.route("/events/explore")
def events_explore():
limit = request.args.get("limit", 10, type=int)
subquery = Event.select(
Event.id,
Event.camera,
Event.label,
Event.zones,
Event.start_time,
Event.end_time,
Event.has_clip,
Event.has_snapshot,
Event.plus_id,
Event.retain_indefinitely,
Event.sub_label,
Event.top_score,
Event.false_positive,
Event.box,
Event.data,
fn.rank()
.over(partition_by=[Event.label], order_by=[Event.start_time.desc()])
.alias("rank"),
fn.COUNT(Event.id).over(partition_by=[Event.label]).alias("event_count"),
).alias("subquery")
query = (
Event.select(
subquery.c.id,
subquery.c.camera,
subquery.c.label,
subquery.c.zones,
subquery.c.start_time,
subquery.c.end_time,
subquery.c.has_clip,
subquery.c.has_snapshot,
subquery.c.plus_id,
subquery.c.retain_indefinitely,
subquery.c.sub_label,
subquery.c.top_score,
subquery.c.false_positive,
subquery.c.box,
subquery.c.data,
subquery.c.event_count,
)
.from_(subquery)
.where(subquery.c.rank <= limit)
.order_by(subquery.c.event_count.desc(), subquery.c.start_time.desc())
.dicts()
)
events = query.iterator()
return jsonify(list(events))
@EventBp.route("/event_ids") @EventBp.route("/event_ids")
def event_ids(): def event_ids():
idString = request.args.get("ids") idString = request.args.get("ids")

View File

@ -179,15 +179,6 @@ export default function SearchFilterGroup({
updateSelectedRange={onUpdateSelectedRange} updateSelectedRange={onUpdateSelectedRange}
/> />
)} )}
{filters.includes("general") && (
<GeneralFilterButton
allLabels={filterValues.labels}
selectedLabels={filter?.labels}
updateLabelFilter={(newLabels) => {
onUpdateFilter({ ...filter, labels: newLabels });
}}
/>
)}
{filters.includes("zone") && allZones.length > 0 && ( {filters.includes("zone") && allZones.length > 0 && (
<ZoneFilterButton <ZoneFilterButton
allZones={filterValues.zones} allZones={filterValues.zones}
@ -197,6 +188,15 @@ export default function SearchFilterGroup({
} }
/> />
)} )}
{filters.includes("general") && (
<GeneralFilterButton
allLabels={filterValues.labels}
selectedLabels={filter?.labels}
updateLabelFilter={(newLabels) => {
onUpdateFilter({ ...filter, labels: newLabels });
}}
/>
)}
{filters.includes("sub") && ( {filters.includes("sub") && (
<SubFilterButton <SubFilterButton
allSubLabels={allSubLabels} allSubLabels={allSubLabels}
@ -247,7 +247,7 @@ function GeneralFilterButton({
<div <div
className={`hidden md:block ${selectedLabels?.length ? "text-selected-foreground" : "text-primary"}`} className={`hidden md:block ${selectedLabels?.length ? "text-selected-foreground" : "text-primary"}`}
> >
Filter All Labels
</div> </div>
</Button> </Button>
); );

View File

@ -11,7 +11,6 @@ import {
import { TimeRange } from "@/types/timeline"; import { TimeRange } from "@/types/timeline";
import { RecordingView } from "@/views/recording/RecordingView"; import { RecordingView } from "@/views/recording/RecordingView";
import SearchView from "@/views/search/SearchView"; import SearchView from "@/views/search/SearchView";
import ExploreView from "@/views/explore/ExploreView";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
@ -119,6 +118,7 @@ export default function Explore() {
]; ];
} }
if (searchSearchParams && Object.keys(searchSearchParams).length !== 0) {
return [ return [
"events", "events",
{ {
@ -134,6 +134,9 @@ export default function Explore() {
include_thumbnails: 0, include_thumbnails: 0,
}, },
]; ];
}
return null;
}, [searchTerm, searchSearchParams, similaritySearch]); }, [searchTerm, searchSearchParams, similaritySearch]);
const { data: searchResults, isLoading } = const { data: searchResults, isLoading } =
@ -225,11 +228,6 @@ export default function Explore() {
); );
} }
} else { } else {
if (
search ||
similaritySearch ||
(searchFilter && Object.keys(searchFilter).length != 0)
) {
return ( return (
<SearchView <SearchView
search={search} search={search}
@ -244,12 +242,5 @@ export default function Explore() {
onOpenSearch={onOpenSearch} onOpenSearch={onOpenSearch}
/> />
); );
} else {
return (
<div className="flex size-full flex-col pt-2 md:py-2">
<ExploreView />
</div>
);
}
} }
} }

View File

@ -1,242 +0,0 @@
import { useApiFilterArgs } from "@/hooks/use-api-filter";
import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { useOverlayState } from "@/hooks/use-overlay-state";
import { FrigateConfig } from "@/types/frigateConfig";
import { RecordingStartingPoint } from "@/types/record";
import {
PartialSearchResult,
SearchFilter,
SearchResult,
} from "@/types/search";
import { TimeRange } from "@/types/timeline";
import { RecordingView } from "@/views/recording/RecordingView";
import SearchView from "@/views/search/SearchView";
import { useCallback, useEffect, useMemo, useState } from "react";
import useSWR from "swr";
export default function Search() {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
// search field handler
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout>();
const [search, setSearch] = useState("");
const [searchTerm, setSearchTerm] = useState("");
const [recording, setRecording] =
useOverlayState<RecordingStartingPoint>("recording");
// search filter
const [searchFilter, setSearchFilter, searchSearchParams] =
useApiFilterArgs<SearchFilter>();
const onUpdateFilter = useCallback(
(newFilter: SearchFilter) => {
setSearchFilter(newFilter);
},
[setSearchFilter],
);
// search api
const [similaritySearch, setSimilaritySearch] =
useState<PartialSearchResult>();
useEffect(() => {
if (
config?.semantic_search.enabled &&
searchSearchParams["search_type"] == "similarity" &&
searchSearchParams["event_id"]?.length != 0 &&
searchFilter
) {
setSimilaritySearch({
id: searchSearchParams["event_id"],
});
// remove event id from url params
const { event_id: _event_id, ...newFilter } = searchFilter;
setSearchFilter(newFilter);
}
// only run similarity search with event_id in the url when coming from review
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (similaritySearch) {
setSimilaritySearch(undefined);
}
if (searchTimeout) {
clearTimeout(searchTimeout);
}
setSearchTimeout(
setTimeout(() => {
setSearchTimeout(undefined);
setSearchTerm(search);
}, 750),
);
// we only want to update the searchTerm when search changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search]);
const searchQuery = useMemo(() => {
if (similaritySearch) {
return [
"events/search",
{
query: similaritySearch.id,
cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["subLabels"],
zones: searchSearchParams["zones"],
before: searchSearchParams["before"],
after: searchSearchParams["after"],
include_thumbnails: 0,
search_type: "similarity",
},
];
}
if (searchTerm) {
return [
"events/search",
{
query: searchTerm,
cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["subLabels"],
zones: searchSearchParams["zones"],
before: searchSearchParams["before"],
after: searchSearchParams["after"],
search_type: searchSearchParams["search_type"],
include_thumbnails: 0,
},
];
}
return [
"events",
{
cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["subLabels"],
zones: searchSearchParams["zones"],
before: searchSearchParams["before"],
after: searchSearchParams["after"],
search_type: searchSearchParams["search_type"],
limit: Object.keys(searchSearchParams).length == 0 ? 20 : null,
in_progress: 0,
include_thumbnails: 0,
},
];
}, [searchTerm, searchSearchParams, similaritySearch]);
const { data: searchResults, isLoading } =
useSWR<SearchResult[]>(searchQuery);
const previewTimeRange = useMemo<TimeRange>(() => {
if (!searchResults) {
return { after: 0, before: 0 };
}
return {
after: Math.min(...searchResults.map((res) => res.start_time)),
before: Math.max(
...searchResults.map((res) => res.end_time ?? Date.now() / 1000),
),
};
}, [searchResults]);
const allPreviews = useCameraPreviews(previewTimeRange, {
autoRefresh: false,
fetchPreviews: searchResults != undefined,
});
// selection
const onOpenSearch = useCallback(
(item: SearchResult) => {
setRecording({
camera: item.camera,
startTime: item.start_time,
severity: "alert",
});
},
[setRecording],
);
const selectedReviewData = useMemo(() => {
if (!recording) {
return undefined;
}
if (!config) {
return undefined;
}
if (!searchResults) {
return undefined;
}
const allCameras = searchFilter?.cameras ?? Object.keys(config.cameras);
return {
camera: recording.camera,
start_time: recording.startTime,
allCameras: allCameras,
};
// previews will not update after item is selected
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recording, searchResults]);
const selectedTimeRange = useMemo(() => {
if (!recording) {
return undefined;
}
const time = new Date(recording.startTime * 1000);
time.setUTCMinutes(0, 0, 0);
const start = time.getTime() / 1000;
time.setHours(time.getHours() + 2);
const end = time.getTime() / 1000;
return {
after: start,
before: end,
};
}, [recording]);
if (recording) {
if (selectedReviewData && selectedTimeRange) {
return (
<RecordingView
startCamera={selectedReviewData.camera}
startTime={selectedReviewData.start_time}
allCameras={selectedReviewData.allCameras}
allPreviews={allPreviews}
timeRange={selectedTimeRange}
updateFilter={onUpdateFilter}
/>
);
}
} else {
return (
<SearchView
search={search}
searchTerm={searchTerm}
searchFilter={searchFilter}
searchResults={searchResults}
isLoading={isLoading}
setSearch={setSearch}
similaritySearch={similaritySearch}
setSimilaritySearch={setSimilaritySearch}
onUpdateFilter={onUpdateFilter}
onOpenSearch={onOpenSearch}
/>
);
}
}

View File

@ -1,6 +1,5 @@
import { Event } from "@/types/event";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { isIOS } from "react-device-detect"; import { isIOS, isMobileOnly } from "react-device-detect";
import useSWR from "swr"; import useSWR from "swr";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -12,8 +11,13 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import { SearchResult } from "@/types/search";
export default function ImageAccordion() { type ExploreViewProps = {
onSelectSearch: (searchResult: SearchResult, detail: boolean) => void;
};
export default function ExploreView({ onSelectSearch }: ExploreViewProps) {
// title // title
useEffect(() => { useEffect(() => {
@ -22,11 +26,11 @@ export default function ImageAccordion() {
// data // data
const { data: events } = useSWR<Event[]>( const { data: events } = useSWR<SearchResult[]>(
[ [
"events", "events/explore",
{ {
limit: 100, limit: isMobileOnly ? 5 : 10,
}, },
], ],
{ {
@ -36,7 +40,7 @@ export default function ImageAccordion() {
const eventsByLabel = useMemo(() => { const eventsByLabel = useMemo(() => {
if (!events) return {}; if (!events) return {};
return events.reduce<Record<string, Event[]>>((acc, event) => { return events.reduce<Record<string, SearchResult[]>>((acc, event) => {
const label = event.label || "Unknown"; const label = event.label || "Unknown";
if (!acc[label]) { if (!acc[label]) {
acc[label] = []; acc[label] = [];
@ -49,19 +53,28 @@ export default function ImageAccordion() {
return ( return (
<div className="space-y-4 overflow-x-hidden p-2"> <div className="space-y-4 overflow-x-hidden p-2">
{Object.entries(eventsByLabel).map(([label, filteredEvents]) => ( {Object.entries(eventsByLabel).map(([label, filteredEvents]) => (
<ThumbnailRow key={label} events={filteredEvents} objectType={label} /> <ThumbnailRow
key={label}
searchResults={filteredEvents}
objectType={label}
onSelectSearch={onSelectSearch}
/>
))} ))}
</div> </div>
); );
} }
type ThumbnailRowType = {
objectType: string;
searchResults?: SearchResult[];
onSelectSearch: (searchResult: SearchResult, detail: boolean) => void;
};
function ThumbnailRow({ function ThumbnailRow({
objectType, objectType,
events, searchResults,
}: { onSelectSearch,
objectType: string; }: ThumbnailRowType) {
events?: Event[];
}) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const navigate = useNavigate(); const navigate = useNavigate();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null); const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
@ -74,20 +87,34 @@ function ThumbnailRow({
}; };
return ( return (
<div className="space-y-2"> <div className="rounded-lg bg-background_alt p-2 md:p-4">
<h2 className="text-lg capitalize">{objectType}</h2> <div className="text-lg capitalize">
<div className="flex flex-row items-center space-x-2 p-2"> {objectType.replaceAll("_", " ")}
{events?.map((event, index) => ( {searchResults && (
<span className="ml-3 text-sm text-secondary-foreground">
(
{
// @ts-expect-error we know this is correct
searchResults[0].event_count
}{" "}
detected objects){" "}
</span>
)}
</div>
<div className="flex flex-row items-center space-x-2 py-2">
{searchResults?.map((event, index) => (
<div <div
key={event.id} key={event.id}
className="relative aspect-square h-[50px] w-full max-w-[50px] md:h-[120px] md:max-w-[120px]" className="relative aspect-square h-auto max-w-[20%] flex-grow md:max-w-[10%]"
onMouseEnter={() => setHoveredIndex(index)} onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)} onMouseLeave={() => setHoveredIndex(null)}
> >
<img <img
className={cn( className={cn(
"absolute h-full w-full rounded-lg object-cover transition-all duration-300 ease-in-out md:rounded-2xl", "absolute h-full w-full rounded-lg object-cover transition-all duration-300 ease-in-out md:rounded-2xl",
hoveredIndex === index ? "z-10 scale-110" : "scale-100", hoveredIndex === index
? "z-10 scale-110 cursor-pointer"
: "scale-100",
)} )}
style={ style={
isIOS isIOS
@ -98,12 +125,9 @@ function ThumbnailRow({
: undefined : undefined
} }
draggable={false} draggable={false}
src={ src={`${apiHost}api/events/${event.id}/thumbnail.jpg`}
event.has_snapshot
? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.jpg`
}
alt={`${objectType} snapshot`} alt={`${objectType} snapshot`}
onClick={() => onSelectSearch(event, true)}
/> />
</div> </div>
))} ))}
@ -114,13 +138,13 @@ function ThumbnailRow({
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<LuArrowRightCircle <LuArrowRightCircle
className="text-secondary-foreground" className="ml-2 text-secondary-foreground transition-all duration-300 hover:text-primary"
size={24} size={24}
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipPortal> <TooltipPortal>
<TooltipContent className="capitalize"> <TooltipContent className="capitalize">
Explore More {objectType}s <ExploreMoreLink objectType={objectType} />
</TooltipContent> </TooltipContent>
</TooltipPortal> </TooltipPortal>
</Tooltip> </Tooltip>
@ -129,3 +153,12 @@ function ThumbnailRow({
</div> </div>
); );
} }
function ExploreMoreLink({ objectType }: { objectType: string }) {
const formattedType = objectType.replaceAll("_", " ");
const label = formattedType.endsWith("s")
? `${formattedType}es`
: `${formattedType}s`;
return <div>Explore More {label}</div>;
}

View File

@ -21,6 +21,7 @@ import { useCallback, useMemo, useState } from "react";
import { isMobileOnly } from "react-device-detect"; import { isMobileOnly } from "react-device-detect";
import { LuImage, LuSearchX, LuText, LuXCircle } from "react-icons/lu"; import { LuImage, LuSearchX, LuText, LuXCircle } from "react-icons/lu";
import useSWR from "swr"; import useSWR from "swr";
import ExploreView from "../explore/ExploreView";
type SearchViewProps = { type SearchViewProps = {
search: string; search: string;
@ -109,7 +110,7 @@ export default function SearchView({
<div <div
className={cn( className={cn(
"relative mb-2 flex h-11 items-center pl-2 pr-2 md:pl-3", "relative flex h-11 items-center pl-2 pr-2 md:pl-3",
config?.semantic_search?.enabled config?.semantic_search?.enabled
? "justify-between" ? "justify-between"
: "justify-center", : "justify-center",
@ -120,7 +121,7 @@ export default function SearchView({
<div <div
className={cn( className={cn(
"relative w-full", "relative w-full",
hasExistingSearch ? "mr-3 md:w-1/3" : "md:ml-[25%] md:w-1/2", hasExistingSearch ? "md:mr-3 md:w-1/3" : "md:ml-[25%] md:w-1/2",
)} )}
> >
<Input <Input
@ -161,7 +162,8 @@ export default function SearchView({
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" /> <ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)} )}
<div className="grid w-full gap-2 px-1 sm:grid-cols-2 md:mx-2 md:grid-cols-4 md:gap-4 3xl:grid-cols-6"> {uniqueResults && (
<div className="mt-2 grid w-full gap-2 px-1 sm:grid-cols-2 md:mx-2 md:grid-cols-4 md:gap-4 3xl:grid-cols-6">
{uniqueResults && {uniqueResults &&
uniqueResults.map((value) => { uniqueResults.map((value) => {
const selected = false; const selected = false;
@ -221,6 +223,12 @@ export default function SearchView({
); );
})} })}
</div> </div>
)}
{!uniqueResults && !isLoading && (
<div className="flex size-full flex-col">
<ExploreView onSelectSearch={onSelectSearch} />
</div>
)}
</div> </div>
</div> </div>
); );