Add score filter to Explore view (#14397)

* backend score filtering and sorting

* score filter frontend

* use input for score filtering

* use correct score on search thumbnail

* add popover to explain top_score

* revert sublabel score calc

* update filters logic

* fix rounding on score

* wait until default view is loaded

* don't turn button to selected style for similarity searches

* clarify language

* fix alert dialog buttons to use correct destructive variant

* use root level top_score for very old events

* better arrangement of thumbnail footer items on smaller screens
This commit is contained in:
Josh Hawkins 2024-10-17 06:30:52 -05:00 committed by GitHub
parent edaccd86d6
commit 8173cd7776
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 353 additions and 136 deletions

View File

@ -45,6 +45,9 @@ class EventsSearchQueryParams(BaseModel):
before: Optional[float] = None before: Optional[float] = None
time_range: Optional[str] = DEFAULT_TIME_RANGE time_range: Optional[str] = DEFAULT_TIME_RANGE
timezone: Optional[str] = "utc" timezone: Optional[str] = "utc"
min_score: Optional[float] = None
max_score: Optional[float] = None
sort: Optional[str] = None
class EventsSummaryQueryParams(BaseModel): class EventsSummaryQueryParams(BaseModel):

View File

@ -348,6 +348,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
search_type = params.search_type search_type = params.search_type
include_thumbnails = params.include_thumbnails include_thumbnails = params.include_thumbnails
limit = params.limit limit = params.limit
sort = params.sort
# Filters # Filters
cameras = params.cameras cameras = params.cameras
@ -355,6 +356,8 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
zones = params.zones zones = params.zones
after = params.after after = params.after
before = params.before before = params.before
min_score = params.min_score
max_score = params.max_score
time_range = params.time_range time_range = params.time_range
# for similarity search # for similarity search
@ -430,6 +433,14 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
if before: if before:
event_filters.append((Event.start_time < before)) event_filters.append((Event.start_time < before))
if min_score is not None and max_score is not None:
event_filters.append((Event.data["score"].between(min_score, max_score)))
else:
if min_score is not None:
event_filters.append((Event.data["score"] >= min_score))
if max_score is not None:
event_filters.append((Event.data["score"] <= max_score))
if time_range != DEFAULT_TIME_RANGE: if time_range != DEFAULT_TIME_RANGE:
tz_name = params.timezone tz_name = params.timezone
hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name) hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name)
@ -554,11 +565,19 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
processed_events.append(processed_event) processed_events.append(processed_event)
# Sort by search distance if search_results are available, otherwise by start_time # Sort by search distance if search_results are available, otherwise by start_time as default
if search_results: if search_results:
processed_events.sort(key=lambda x: x.get("search_distance", float("inf"))) processed_events.sort(key=lambda x: x.get("search_distance", float("inf")))
else: else:
processed_events.sort(key=lambda x: x["start_time"], reverse=True) if sort == "score_asc":
processed_events.sort(key=lambda x: x["score"])
elif sort == "score_desc":
processed_events.sort(key=lambda x: x["score"], reverse=True)
elif sort == "date_asc":
processed_events.sort(key=lambda x: x["start_time"])
else:
# "date_desc" default
processed_events.sort(key=lambda x: x["start_time"], reverse=True)
# Limit the number of events returned # Limit the number of events returned
processed_events = processed_events[:limit] processed_events = processed_events[:limit]

View File

@ -34,6 +34,7 @@ import { toast } from "sonner";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { buttonVariants } from "../ui/button";
type ReviewCardProps = { type ReviewCardProps = {
event: ReviewSegment; event: ReviewSegment;
@ -228,7 +229,10 @@ export default function ReviewCard({
<AlertDialogCancel onClick={() => setOptionsOpen(false)}> <AlertDialogCancel onClick={() => setOptionsOpen(false)}>
Cancel Cancel
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction className="bg-destructive" onClick={onDelete}> <AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onDelete}
>
Delete Delete
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
@ -295,7 +299,10 @@ export default function ReviewCard({
<AlertDialogCancel onClick={() => setOptionsOpen(false)}> <AlertDialogCancel onClick={() => setOptionsOpen(false)}>
Cancel Cancel
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction className="bg-destructive" onClick={onDelete}> <AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onDelete}
>
Delete Delete
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View File

@ -90,8 +90,10 @@ export default function SearchThumbnail({
onClick={() => onClick(searchResult)} onClick={() => onClick(searchResult)}
> >
{getIconForLabel(objectLabel, "size-3 text-white")} {getIconForLabel(objectLabel, "size-3 text-white")}
{Math.floor( {Math.round(
searchResult.score ?? searchResult.data.top_score * 100, (searchResult.data.score ??
searchResult.data.top_score ??
searchResult.top_score) * 100,
)} )}
% %
</Chip> </Chip>

View File

@ -32,9 +32,12 @@ import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { MdImageSearch } from "react-icons/md"; import { MdImageSearch } from "react-icons/md";
import { isMobileOnly } from "react-device-detect"; import { isMobileOnly } from "react-device-detect";
import { buttonVariants } from "../ui/button";
import { cn } from "@/lib/utils";
type SearchThumbnailProps = { type SearchThumbnailProps = {
searchResult: SearchResult; searchResult: SearchResult;
columns: number;
findSimilar: () => void; findSimilar: () => void;
refreshResults: () => void; refreshResults: () => void;
showObjectLifecycle: () => void; showObjectLifecycle: () => void;
@ -42,6 +45,7 @@ type SearchThumbnailProps = {
export default function SearchThumbnailFooter({ export default function SearchThumbnailFooter({
searchResult, searchResult,
columns,
findSimilar, findSimilar,
refreshResults, refreshResults,
showObjectLifecycle, showObjectLifecycle,
@ -95,7 +99,7 @@ export default function SearchThumbnailFooter({
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className="bg-destructive" className={buttonVariants({ variant: "destructive" })}
onClick={handleDelete} onClick={handleDelete}
> >
Delete Delete
@ -113,104 +117,112 @@ export default function SearchThumbnailFooter({
}} }}
/> />
<div className="flex flex-col items-start text-xs text-primary-variant"> <div
{searchResult.end_time ? ( className={cn(
<TimeAgo time={searchResult.start_time * 1000} dense /> "flex w-full flex-row items-center justify-between",
) : ( columns > 4 &&
<div> "items-start sm:flex-col sm:gap-2 lg:flex-row lg:items-center lg:gap-1",
<ActivityIndicator size={14} />
</div>
)} )}
{formattedDate} >
</div> <div className="flex flex-col items-start text-xs text-primary-variant">
<div className="flex flex-row items-center justify-end gap-6 md:gap-4"> {searchResult.end_time ? (
{!isMobileOnly && <TimeAgo time={searchResult.start_time * 1000} dense />
config?.plus?.enabled && ) : (
searchResult.has_snapshot && <div>
searchResult.end_time && <ActivityIndicator size={14} />
!searchResult.plus_id && ( </div>
)}
{formattedDate}
</div>
<div className="flex flex-row items-center justify-end gap-6 md:gap-4">
{!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>
)}
{config?.semantic_search?.enabled && (
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<FrigatePlusIcon <MdImageSearch
className="size-5 cursor-pointer text-primary-variant hover:text-primary" className="size-5 cursor-pointer text-primary-variant hover:text-primary"
onClick={() => setShowFrigatePlus(true)} onClick={findSimilar}
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Submit to Frigate+</TooltipContent> <TooltipContent>Find similar</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{config?.semantic_search?.enabled && ( <DropdownMenu>
<Tooltip> <DropdownMenuTrigger>
<TooltipTrigger> <LuMoreVertical className="size-5 cursor-pointer text-primary-variant hover:text-primary" />
<MdImageSearch </DropdownMenuTrigger>
className="size-5 cursor-pointer text-primary-variant hover:text-primary" <DropdownMenuContent align={"end"}>
onClick={findSimilar} {searchResult.has_clip && (
/> <DropdownMenuItem>
</TooltipTrigger> <a
<TooltipContent>Find similar</TooltipContent> className="justify_start flex items-center"
</Tooltip> href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
)} download={`${searchResult.camera}_${searchResult.label}.mp4`}
>
<DropdownMenu> <LuDownload className="mr-2 size-4" />
<DropdownMenuTrigger> <span>Download video</span>
<LuMoreVertical className="size-5 cursor-pointer text-primary-variant hover:text-primary" /> </a>
</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>
)} )}
<DropdownMenuItem {searchResult.has_snapshot && (
className="cursor-pointer" <DropdownMenuItem>
onClick={() => setDeleteDialogOpen(true)} <a
> className="justify_start flex items-center"
<LuTrash2 className="mr-2 size-4" /> href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
<span>Delete</span> download={`${searchResult.camera}_${searchResult.label}.jpg`}
</DropdownMenuItem> >
</DropdownMenuContent> <LuCamera className="mr-2 size-4" />
</DropdownMenu> <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>
</> </>
); );

View File

@ -3,7 +3,7 @@ import { isDesktop, isMobile } from "react-device-detect";
import useSWR from "swr"; import useSWR from "swr";
import { MdHome } from "react-icons/md"; import { MdHome } from "react-icons/md";
import { usePersistedOverlayState } from "@/hooks/use-overlay-state"; import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
import { Button } from "../ui/button"; import { Button, buttonVariants } from "../ui/button";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { LuPencil, LuPlus } from "react-icons/lu"; import { LuPencil, LuPlus } from "react-icons/lu";
@ -518,7 +518,10 @@ export function CameraGroupRow({
</AlertDialogDescription> </AlertDialogDescription>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onDeleteGroup}> <AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onDeleteGroup}
>
Delete Delete
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View File

@ -1,7 +1,7 @@
import { FaCircleCheck } from "react-icons/fa6"; import { FaCircleCheck } from "react-icons/fa6";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import axios from "axios"; import axios from "axios";
import { Button } from "../ui/button"; import { Button, buttonVariants } from "../ui/button";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { FaCompactDisc } from "react-icons/fa"; import { FaCompactDisc } from "react-icons/fa";
import { HiTrash } from "react-icons/hi"; import { HiTrash } from "react-icons/hi";
@ -79,7 +79,10 @@ export default function ReviewActionGroup({
</AlertDialogDescription> </AlertDialogDescription>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction className="bg-destructive" onClick={onDelete}> <AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onDelete}
>
Delete Delete
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View File

@ -8,6 +8,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { buttonVariants } from "../ui/button";
type DeleteSearchDialogProps = { type DeleteSearchDialogProps = {
isOpen: boolean; isOpen: boolean;
@ -35,7 +36,7 @@ export function DeleteSearchDialog({
<AlertDialogCancel onClick={onClose}>Cancel</AlertDialogCancel> <AlertDialogCancel onClick={onClose}>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={onConfirm} onClick={onConfirm}
className="bg-destructive text-white" className={buttonVariants({ variant: "destructive" })}
> >
Delete Delete
</AlertDialogAction> </AlertDialogAction>

View File

@ -201,10 +201,13 @@ export default function InputWithTags({
allSuggestions[type as FilterType]?.includes(value) || allSuggestions[type as FilterType]?.includes(value) ||
type == "before" || type == "before" ||
type == "after" || type == "after" ||
type == "time_range" type == "time_range" ||
type == "min_score" ||
type == "max_score"
) { ) {
const newFilters = { ...filters }; const newFilters = { ...filters };
let timestamp = 0; let timestamp = 0;
let score = 0;
switch (type) { switch (type) {
case "before": case "before":
@ -244,6 +247,40 @@ export default function InputWithTags({
newFilters[type] = timestamp / 1000; newFilters[type] = timestamp / 1000;
} }
break; break;
case "min_score":
case "max_score":
score = parseInt(value);
if (score >= 0) {
// Check for conflicts between min_score and max_score
if (
type === "min_score" &&
filters.max_score !== undefined &&
score > filters.max_score * 100
) {
toast.error(
"The 'min_score' must be less than or equal to the 'max_score'.",
{
position: "top-center",
},
);
return;
}
if (
type === "max_score" &&
filters.min_score !== undefined &&
score < filters.min_score * 100
) {
toast.error(
"The 'max_score' must be greater than or equal to the 'min_score'.",
{
position: "top-center",
},
);
return;
}
newFilters[type] = score / 100;
}
break;
case "time_range": case "time_range":
newFilters[type] = value; newFilters[type] = value;
break; break;
@ -302,6 +339,8 @@ export default function InputWithTags({
} - ${ } - ${
config?.ui.time_format === "24hour" ? endTime : convertTo12Hour(endTime) config?.ui.time_format === "24hour" ? endTime : convertTo12Hour(endTime)
}`; }`;
} else if (filterType === "min_score" || filterType === "max_score") {
return Math.round(Number(filterValues) * 100).toString() + "%";
} else { } else {
return filterValues as string; return filterValues as string;
} }
@ -320,7 +359,11 @@ export default function InputWithTags({
isValidTimeRange( isValidTimeRange(
trimmedValue.replace("-", ","), trimmedValue.replace("-", ","),
config?.ui.time_format, config?.ui.time_format,
)) )) ||
((filterType === "min_score" || filterType === "max_score") &&
!isNaN(Number(trimmedValue)) &&
Number(trimmedValue) >= 50 &&
Number(trimmedValue) <= 100)
) { ) {
createFilter( createFilter(
filterType, filterType,

View File

@ -62,6 +62,12 @@ import { Card, CardContent } from "@/components/ui/card";
import useImageLoaded from "@/hooks/use-image-loaded"; import useImageLoaded from "@/hooks/use-image-loaded";
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator"; import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
import { GenericVideoPlayer } from "@/components/player/GenericVideoPlayer"; import { GenericVideoPlayer } from "@/components/player/GenericVideoPlayer";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { LuInfo } from "react-icons/lu";
const SEARCH_TABS = [ const SEARCH_TABS = [
"details", "details",
@ -279,7 +285,7 @@ function ObjectDetailsTab({
return 0; return 0;
} }
const value = search.score ?? search.data.top_score; const value = search.data.top_score;
return Math.round(value * 100); return Math.round(value * 100);
}, [search]); }, [search]);
@ -369,7 +375,24 @@ function ObjectDetailsTab({
</div> </div>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">Score</div> <div className="text-sm text-primary/40">
<div className="flex flex-row items-center gap-1">
Top Score
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80">
The top score is the highest median score for the tracked
object, so this may differ from the score shown on the
search result thumbnail.
</PopoverContent>
</Popover>
</div>
</div>
<div className="text-sm"> <div className="text-sm">
{score}%{subLabelScore && ` (${subLabelScore}%)`} {score}%{subLabelScore && ` (${subLabelScore}%)`}
</div> </div>

View File

@ -23,6 +23,8 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DualThumbSlider } from "@/components/ui/slider";
import { Input } from "@/components/ui/input";
type SearchFilterDialogProps = { type SearchFilterDialogProps = {
config?: FrigateConfig; config?: FrigateConfig;
@ -46,6 +48,12 @@ export default function SearchFilterDialog({
const [currentFilter, setCurrentFilter] = useState(filter ?? {}); const [currentFilter, setCurrentFilter] = useState(filter ?? {});
const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]); const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]);
useEffect(() => {
if (filter) {
setCurrentFilter(filter);
}
}, [filter]);
// state // state
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -54,9 +62,12 @@ export default function SearchFilterDialog({
() => () =>
currentFilter && currentFilter &&
(currentFilter.time_range || (currentFilter.time_range ||
(currentFilter.min_score ?? 0) > 0.5 ||
(currentFilter.max_score ?? 1) < 1 ||
(currentFilter.zones?.length ?? 0) > 0 || (currentFilter.zones?.length ?? 0) > 0 ||
(currentFilter.sub_labels?.length ?? 0) > 0 || (currentFilter.sub_labels?.length ?? 0) > 0 ||
(currentFilter.search_type?.length ?? 2) !== 2), (!currentFilter.search_type?.includes("similarity") &&
(currentFilter.search_type?.length ?? 2) !== 2)),
[currentFilter], [currentFilter],
); );
@ -97,6 +108,13 @@ export default function SearchFilterDialog({
setCurrentFilter({ ...currentFilter, sub_labels: newSubLabels }) setCurrentFilter({ ...currentFilter, sub_labels: newSubLabels })
} }
/> />
<ScoreFilterContent
minScore={currentFilter.min_score}
maxScore={currentFilter.max_score}
setScoreRange={(min, max) =>
setCurrentFilter({ ...currentFilter, min_score: min, max_score: max })
}
/>
{config?.semantic_search?.enabled && {config?.semantic_search?.enabled &&
!currentFilter?.search_type?.includes("similarity") && ( !currentFilter?.search_type?.includes("similarity") && (
<SearchTypeContent <SearchTypeContent
@ -133,6 +151,8 @@ export default function SearchFilterDialog({
zones: undefined, zones: undefined,
sub_labels: undefined, sub_labels: undefined,
search_type: ["thumbnail", "description"], search_type: ["thumbnail", "description"],
min_score: undefined,
max_score: undefined,
})); }));
}} }}
> >
@ -420,6 +440,58 @@ export function SubFilterContent({
); );
} }
type ScoreFilterContentProps = {
minScore: number | undefined;
maxScore: number | undefined;
setScoreRange: (min: number | undefined, max: number | undefined) => void;
};
export function ScoreFilterContent({
minScore,
maxScore,
setScoreRange,
}: ScoreFilterContentProps) {
return (
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<div className="mb-3 text-lg">Score</div>
<div className="flex items-center gap-1">
<Input
className="w-12"
inputMode="numeric"
value={Math.round((minScore ?? 0.5) * 100)}
onChange={(e) => {
const value = e.target.value;
if (value) {
setScoreRange(parseInt(value) / 100.0, maxScore ?? 1.0);
}
}}
/>
<DualThumbSlider
className="w-full"
min={0.5}
max={1.0}
step={0.01}
value={[minScore ?? 0.5, maxScore ?? 1.0]}
onValueChange={([min, max]) => setScoreRange(min, max)}
/>
<Input
className="w-12"
inputMode="numeric"
value={Math.round((maxScore ?? 1.0) * 100)}
onChange={(e) => {
const value = e.target.value;
if (value) {
setScoreRange(minScore ?? 0.5, parseInt(value) / 100.0);
}
}}
/>
</div>
</div>
);
}
type SearchTypeContentProps = { type SearchTypeContentProps = {
searchSources: SearchSource[] | undefined; searchSources: SearchSource[] | undefined;
setSearchSources: (sources: SearchSource[] | undefined) => void; setSearchSources: (sources: SearchSource[] | undefined) => void;

View File

@ -35,6 +35,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { reviewQueries } from "@/utils/zoneEdutUtil"; import { reviewQueries } from "@/utils/zoneEdutUtil";
import IconWrapper from "../ui/icon-wrapper"; import IconWrapper from "../ui/icon-wrapper";
import { StatusBarMessagesContext } from "@/context/statusbar-provider"; import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { buttonVariants } from "../ui/button";
type PolygonItemProps = { type PolygonItemProps = {
polygon: Polygon; polygon: Polygon;
@ -257,7 +258,10 @@ export default function PolygonItem({
</AlertDialogDescription> </AlertDialogDescription>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}> <AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={handleDelete}
>
Delete Delete
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View File

@ -1,6 +1,6 @@
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { useState } from "react"; import { useState } from "react";
import { isDesktop } from "react-device-detect"; import { isDesktop, isMobileOnly } from "react-device-detect";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import { FaCog } from "react-icons/fa"; import { FaCog } from "react-icons/fa";
@ -40,7 +40,7 @@ export default function SearchSettings({
<div className={cn(className, "my-3 space-y-5 py-3 md:mt-0 md:py-0")}> <div className={cn(className, "my-3 space-y-5 py-3 md:mt-0 md:py-0")}>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<div className="text-md">Default Search View</div> <div className="text-md">Default View</div>
<div className="space-y-1 text-xs text-muted-foreground"> <div className="space-y-1 text-xs text-muted-foreground">
When no filters are selected, display a summary of the most recent When no filters are selected, display a summary of the most recent
tracked objects per label, or display an unfiltered grid. tracked objects per label, or display an unfiltered grid.
@ -68,26 +68,32 @@ export default function SearchSettings({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<DropdownMenuSeparator /> {!isMobileOnly && (
<div className="flex w-full flex-col space-y-4"> <>
<div className="space-y-0.5"> <DropdownMenuSeparator />
<div className="text-md">Grid Columns</div> <div className="flex w-full flex-col space-y-4">
<div className="space-y-1 text-xs text-muted-foreground"> <div className="space-y-0.5">
Select the number of columns in the results grid. <div className="text-md">Grid Columns</div>
<div className="space-y-1 text-xs text-muted-foreground">
Select the number of columns in the grid view.
</div>
</div>
<div className="flex items-center space-x-4">
<Slider
value={[columns]}
onValueChange={([value]) => setColumns(value)}
max={6}
min={2}
step={1}
className="flex-grow"
/>
<span className="w-9 text-center text-sm font-medium">
{columns}
</span>
</div>
</div> </div>
</div> </>
<div className="flex items-center space-x-4"> )}
<Slider
value={[columns]}
onValueChange={([value]) => setColumns(value)}
max={6}
min={2}
step={1}
className="flex-grow"
/>
<span className="w-9 text-center text-sm font-medium">{columns}</span>
</div>
</div>
</div> </div>
); );

View File

@ -14,6 +14,7 @@ import { ModelState } from "@/types/ws";
import { formatSecondsToDuration } from "@/utils/dateUtil"; import { formatSecondsToDuration } from "@/utils/dateUtil";
import SearchView from "@/views/search/SearchView"; import SearchView from "@/views/search/SearchView";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { isMobileOnly } from "react-device-detect";
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu"; import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { TbExclamationCircle } from "react-icons/tb"; import { TbExclamationCircle } from "react-icons/tb";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@ -32,11 +33,16 @@ export default function Explore() {
// grid // grid
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4); const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
const gridColumns = useMemo(() => columnCount ?? 4, [columnCount]); const gridColumns = useMemo(() => {
if (isMobileOnly) {
return 2;
}
return columnCount ?? 4;
}, [columnCount]);
// default layout // default layout
const [defaultView, setDefaultView] = usePersistence( const [defaultView, setDefaultView, defaultViewLoaded] = usePersistence(
"exploreDefaultView", "exploreDefaultView",
"summary", "summary",
); );
@ -103,6 +109,8 @@ export default function Explore() {
after: searchSearchParams["after"], after: searchSearchParams["after"],
time_range: searchSearchParams["time_range"], time_range: searchSearchParams["time_range"],
search_type: searchSearchParams["search_type"], search_type: searchSearchParams["search_type"],
min_score: searchSearchParams["min_score"],
max_score: searchSearchParams["max_score"],
limit: limit:
Object.keys(searchSearchParams).length == 0 ? API_LIMIT : undefined, Object.keys(searchSearchParams).length == 0 ? API_LIMIT : undefined,
timezone, timezone,
@ -129,6 +137,8 @@ export default function Explore() {
after: searchSearchParams["after"], after: searchSearchParams["after"],
time_range: searchSearchParams["time_range"], time_range: searchSearchParams["time_range"],
search_type: searchSearchParams["search_type"], search_type: searchSearchParams["search_type"],
min_score: searchSearchParams["min_score"],
max_score: searchSearchParams["max_score"],
event_id: searchSearchParams["event_id"], event_id: searchSearchParams["event_id"],
timezone, timezone,
include_thumbnails: 0, include_thumbnails: 0,
@ -270,12 +280,13 @@ export default function Explore() {
}; };
if ( if (
config?.semantic_search.enabled && !defaultViewLoaded ||
(!reindexState || (config?.semantic_search.enabled &&
!textModelState || (!reindexState ||
!textTokenizerState || !textModelState ||
!visionModelState || !textTokenizerState ||
!visionFeatureExtractorState) !visionModelState ||
!visionFeatureExtractorState))
) { ) {
return ( return (
<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" />

View File

@ -35,6 +35,7 @@ export type SearchResult = {
zones: string[]; zones: string[];
search_source: SearchSource; search_source: SearchSource;
search_distance: number; search_distance: number;
top_score: number; // for old events
data: { data: {
top_score: number; top_score: number;
score: number; score: number;
@ -56,6 +57,8 @@ export type SearchFilter = {
zones?: string[]; zones?: string[];
before?: number; before?: number;
after?: number; after?: number;
min_score?: number;
max_score?: number;
time_range?: string; time_range?: string;
search_type?: SearchSource[]; search_type?: SearchSource[];
event_id?: string; event_id?: string;
@ -71,6 +74,8 @@ export type SearchQueryParams = {
zones?: string[]; zones?: string[];
before?: string; before?: string;
after?: string; after?: string;
min_score?: number;
max_score?: number;
search_type?: string; search_type?: string;
limit?: number; limit?: number;
in_progress?: number; in_progress?: number;

View File

@ -144,6 +144,8 @@ export default function SearchView({
: ["12:00AM-11:59PM"], : ["12:00AM-11:59PM"],
before: [formatDateToLocaleString()], before: [formatDateToLocaleString()],
after: [formatDateToLocaleString(-5)], after: [formatDateToLocaleString(-5)],
min_score: ["50"],
max_score: ["100"],
}), }),
[config, allLabels, allZones, allSubLabels], [config, allLabels, allZones, allSubLabels],
); );
@ -385,7 +387,7 @@ export default function SearchView({
key={value.id} key={value.id}
ref={(item) => (itemRefs.current[index] = item)} ref={(item) => (itemRefs.current[index] = item)}
data-start={value.start_time} data-start={value.start_time}
className="review-item relative rounded-lg" className="review-item relative flex flex-col rounded-lg"
> >
<div <div
className={cn( className={cn(
@ -400,9 +402,10 @@ export default function SearchView({
<div <div
className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `shadow-selected outline-selected` : "outline-transparent duration-500"}`} className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `shadow-selected outline-selected` : "outline-transparent duration-500"}`}
/> />
<div className="flex w-full items-center justify-between rounded-b-lg border border-t-0 bg-card p-3 text-card-foreground"> <div className="flex w-full grow items-center justify-between rounded-b-lg border border-t-0 bg-card p-3 text-card-foreground">
<SearchThumbnailFooter <SearchThumbnailFooter
searchResult={value} searchResult={value}
columns={columns}
findSimilar={() => { findSimilar={() => {
if (config?.semantic_search.enabled) { if (config?.semantic_search.enabled) {
setSimilaritySearch(value); setSimilaritySearch(value);