Merge branch 'dev' into updated-documentation

This commit is contained in:
Rui Alves 2024-10-16 13:56:37 +01:00
commit 39afee784b
23 changed files with 1442 additions and 996 deletions

View File

@ -357,6 +357,7 @@ def create_user(request: Request, body: AppPostUsersBody):
{
User.username: body.username,
User.password_hash: password_hash,
User.notification_tokens: [],
}
).execute()
return JSONResponse(content={"username": body.username})

View File

@ -7,6 +7,7 @@ import os
import subprocess as sp
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path as FilePath
from urllib.parse import unquote
import cv2
@ -450,8 +451,27 @@ def recording_clip(
camera_name: str,
start_ts: float,
end_ts: float,
download: bool = False,
):
def run_download(ffmpeg_cmd: list[str], file_path: str):
with sp.Popen(
ffmpeg_cmd,
stderr=sp.PIPE,
stdout=sp.PIPE,
text=False,
) as ffmpeg:
while True:
data = ffmpeg.stdout.read(1024)
if data is not None:
yield data
else:
if ffmpeg.returncode and ffmpeg.returncode != 0:
logger.error(
f"Failed to generate clip, ffmpeg logs: {ffmpeg.stderr.read()}"
)
else:
FilePath(file_path).unlink(missing_ok=True)
break
recordings = (
Recordings.select(
Recordings.path,
@ -467,18 +487,18 @@ def recording_clip(
.order_by(Recordings.start_time.asc())
)
playlist_lines = []
clip: Recordings
for clip in recordings:
playlist_lines.append(f"file '{clip.path}'")
# if this is the starting clip, add an inpoint
if clip.start_time < start_ts:
playlist_lines.append(f"inpoint {int(start_ts - clip.start_time)}")
# if this is the ending clip, add an outpoint
if clip.end_time > end_ts:
playlist_lines.append(f"outpoint {int(end_ts - clip.start_time)}")
file_name = sanitize_filename(f"clip_{camera_name}_{start_ts}-{end_ts}.mp4")
file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt")
file_path = f"/tmp/cache/{file_name}"
with open(file_path, "w") as file:
clip: Recordings
for clip in recordings:
file.write(f"file '{clip.path}'\n")
# if this is the starting clip, add an inpoint
if clip.start_time < start_ts:
file.write(f"inpoint {int(start_ts - clip.start_time)}\n")
# if this is the ending clip, add an outpoint
if clip.end_time > end_ts:
file.write(f"outpoint {int(end_ts - clip.start_time)}\n")
if len(file_name) > 1000:
return JSONResponse(
@ -489,67 +509,32 @@ def recording_clip(
status_code=403,
)
path = os.path.join(CLIPS_DIR, f"cache/{file_name}")
config: FrigateConfig = request.app.frigate_config
if not os.path.exists(path):
ffmpeg_cmd = [
config.ffmpeg.ffmpeg_path,
"-hide_banner",
"-y",
"-protocol_whitelist",
"pipe,file",
"-f",
"concat",
"-safe",
"0",
"-i",
"/dev/stdin",
"-c",
"copy",
"-movflags",
"+faststart",
path,
]
p = sp.run(
ffmpeg_cmd,
input="\n".join(playlist_lines),
encoding="ascii",
capture_output=True,
)
ffmpeg_cmd = [
config.ffmpeg.ffmpeg_path,
"-hide_banner",
"-y",
"-protocol_whitelist",
"pipe,file",
"-f",
"concat",
"-safe",
"0",
"-i",
file_path,
"-c",
"copy",
"-movflags",
"frag_keyframe+empty_moov",
"-f",
"mp4",
"pipe:",
]
if p.returncode != 0:
logger.error(p.stderr)
return JSONResponse(
content={
"success": False,
"message": "Could not create clip from recordings",
},
status_code=500,
)
else:
logger.debug(
f"Ignoring subsequent request for {path} as it already exists in the cache."
)
headers = {
"Content-Description": "File Transfer",
"Cache-Control": "no-cache",
"Content-Type": "video/mp4",
"Content-Length": str(os.path.getsize(path)),
# nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
"X-Accel-Redirect": f"/clips/cache/{file_name}",
}
if download:
headers["Content-Disposition"] = "attachment; filename=%s" % file_name
return FileResponse(
path,
return StreamingResponse(
run_download(ffmpeg_cmd, file_path),
media_type="video/mp4",
filename=file_name,
headers=headers,
)
@ -1028,7 +1013,7 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False
@router.get("/events/{event_id}/clip.mp4")
def event_clip(request: Request, event_id: str, download: bool = False):
def event_clip(request: Request, event_id: str):
try:
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
@ -1048,7 +1033,7 @@ def event_clip(request: Request, event_id: str, download: bool = False):
end_ts = (
datetime.now().timestamp() if event.end_time is None else event.end_time
)
return recording_clip(request, event.camera, event.start_time, end_ts, download)
return recording_clip(request, event.camera, event.start_time, end_ts)
headers = {
"Content-Description": "File Transfer",
@ -1059,9 +1044,6 @@ def event_clip(request: Request, event_id: str, download: bool = False):
"X-Accel-Redirect": f"/clips/{file_name}",
}
if download:
headers["Content-Disposition"] = "attachment; filename=%s" % file_name
return FileResponse(
clip_path,
media_type="video/mp4",

View File

@ -174,7 +174,12 @@ class Embeddings:
return embedding
def batch_upsert_description(self, event_descriptions: dict[str, str]) -> ndarray:
embeddings = self.text_embedding(list(event_descriptions.values()))
# upsert embeddings one by one to avoid token limit
embeddings = []
for desc in event_descriptions.values():
embeddings.append(self.text_embedding([desc])[0])
ids = list(event_descriptions.keys())
items = []

View File

@ -4,6 +4,8 @@ import importlib
import os
from typing import Optional
from playhouse.shortcuts import model_to_dict
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
from frigate.models import Event
@ -36,8 +38,9 @@ class GenAIClient:
) -> Optional[str]:
"""Generate a description for the frame."""
prompt = camera_config.genai.object_prompts.get(
event.label, camera_config.genai.prompt
).format(**event)
event.label,
camera_config.genai.prompt,
).format(**model_to_dict(event))
return self._send(prompt, thumbnails)
def _init_provider(self):

View File

@ -142,6 +142,8 @@ class RecordingMaintainer(threading.Thread):
)
)
)
# see if the recording mover is too slow and segments need to be deleted
if processed_segment_count > keep_count:
logger.warning(
f"Unable to keep up with recording segments in cache for {camera}. Keeping the {keep_count} most recent segments out of {processed_segment_count} and discarding the rest..."
@ -153,6 +155,21 @@ class RecordingMaintainer(threading.Thread):
self.end_time_cache.pop(cache_path, None)
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
# see if detection has failed and unprocessed segments need to be deleted
unprocessed_segment_count = (
len(grouped_recordings[camera]) - processed_segment_count
)
if unprocessed_segment_count > keep_count:
logger.warning(
f"Too many unprocessed recording segments in cache for {camera}. This likely indicates an issue with the detect stream, keeping the {keep_count} most recent segments out of {unprocessed_segment_count} and discarding the rest..."
)
to_remove = grouped_recordings[camera][:-keep_count]
for rec in to_remove:
cache_path = rec["cache_path"]
Path(cache_path).unlink(missing_ok=True)
self.end_time_cache.pop(cache_path, None)
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
tasks = []
for camera, recordings in grouped_recordings.items():
# clear out all the object recording info for old frames

View File

@ -183,16 +183,11 @@ def update_yaml_from_url(file_path, url):
update_yaml_file(file_path, key_path, new_value_list)
else:
value = new_value_list[0]
if "," in value:
# Skip conversion if we're a mask or zone string
update_yaml_file(file_path, key_path, value)
else:
try:
value = ast.literal_eval(value)
except (ValueError, SyntaxError):
pass
update_yaml_file(file_path, key_path, value)
try:
# no need to convert if we have a mask/zone string
value = ast.literal_eval(value) if "," not in value else value
except (ValueError, SyntaxError):
pass
update_yaml_file(file_path, key_path, value)

View File

@ -0,0 +1,65 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { FaDownload } from "react-icons/fa";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
type DownloadVideoButtonProps = {
source: string;
camera: string;
startTime: number;
};
export function DownloadVideoButton({
source,
camera,
startTime,
}: DownloadVideoButtonProps) {
const [isDownloading, setIsDownloading] = useState(false);
const formattedDate = formatUnixTimestampToDateTime(startTime, {
strftime_fmt: "%D-%T",
time_style: "medium",
date_style: "medium",
});
const filename = `${camera}_${formattedDate}.mp4`;
const handleDownloadStart = () => {
setIsDownloading(true);
toast.success("Your review item video has started downloading.", {
position: "top-center",
});
};
const handleDownloadEnd = () => {
setIsDownloading(false);
toast.success("Download completed successfully.", {
position: "top-center",
});
};
return (
<div className="flex justify-center">
<Button
asChild
disabled={isDownloading}
className="flex items-center gap-2"
size="sm"
>
<a
href={source}
download={filename}
onClick={handleDownloadStart}
onBlur={handleDownloadEnd}
>
{isDownloading ? (
<ActivityIndicator className="size-4" />
) : (
<FaDownload className="size-4 text-secondary-foreground" />
)}
</a>
</Button>
</div>
);
}

View File

@ -1,50 +1,56 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { useApiHost } from "@/api";
import { getIconForLabel } from "@/utils/iconUtil";
import TimeAgo from "../dynamic/TimeAgo";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { isIOS, isSafari } from "react-device-detect";
import Chip from "@/components/indicators/Chip";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import useImageLoaded from "@/hooks/use-image-loaded";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
import ActivityIndicator from "../indicators/activity-indicator";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { SearchResult } from "@/types/search";
import useContextMenu from "@/hooks/use-contextmenu";
import { cn } from "@/lib/utils";
import { TooltipPortal } from "@radix-ui/react-tooltip";
type SearchThumbnailProps = {
searchResult: SearchResult;
findSimilar: () => void;
onClick: (searchResult: SearchResult) => void;
};
export default function SearchThumbnail({
searchResult,
findSimilar,
onClick,
}: SearchThumbnailProps) {
const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
useContextMenu(imgRef, findSimilar);
// interactions
const handleOnClick = useCallback(() => {
onClick(searchResult);
}, [searchResult, onClick]);
// date
const objectLabel = useMemo(() => {
if (
!config ||
!searchResult.sub_label ||
!config.model.attributes_map[searchResult.label]
) {
return searchResult.label;
}
const formattedDate = useFormattedTimestamp(
searchResult.start_time,
config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p",
config?.ui.timezone,
);
if (
config.model.attributes_map[searchResult.label].includes(
searchResult.sub_label,
)
) {
return searchResult.sub_label;
}
return `${searchResult.label}-verified`;
}, [config, searchResult]);
return (
<div className="relative size-full cursor-pointer" onClick={handleOnClick}>
@ -80,17 +86,21 @@ export default function SearchThumbnail({
<TooltipTrigger asChild>
<div className="mx-3 pb-1 text-sm text-white">
<Chip
className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500`}
className={`z-0 flex items-center justify-between gap-1 space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs`}
onClick={() => onClick(searchResult)}
>
{getIconForLabel(searchResult.label, "size-3 text-white")}
{getIconForLabel(objectLabel, "size-3 text-white")}
{Math.floor(
searchResult.score ?? searchResult.data.top_score * 100,
)}
%
</Chip>
</div>
</TooltipTrigger>
</div>
<TooltipPortal>
<TooltipContent className="capitalize">
{[...new Set([searchResult.label])]
{[objectLabel]
.filter(
(item) => item !== undefined && !item.includes("-verified"),
)
@ -103,18 +113,7 @@ export default function SearchThumbnail({
</Tooltip>
</div>
<div className="rounded-t-l pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/60 to-transparent"></div>
<div className="rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[20%] w-full bg-gradient-to-t from-black/60 to-transparent">
<div className="mx-3 flex h-full items-end justify-between pb-1 text-sm text-white">
{searchResult.end_time ? (
<TimeAgo time={searchResult.start_time * 1000} dense />
) : (
<div>
<ActivityIndicator size={24} />
</div>
)}
{formattedDate}
</div>
</div>
<div className="rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 z-10 flex h-[20%] items-end bg-gradient-to-t from-black/60 to-transparent"></div>
</div>
</div>
);

View File

@ -0,0 +1,217 @@
import { useCallback, useState } from "react";
import TimeAgo from "../dynamic/TimeAgo";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
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 {
DropdownMenu,
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";
type SearchThumbnailProps = {
searchResult: SearchResult;
findSimilar: () => void;
refreshResults: () => void;
showObjectLifecycle: () => void;
};
export default function SearchThumbnailFooter({
searchResult,
findSimilar,
refreshResults,
showObjectLifecycle,
}: SearchThumbnailProps) {
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
const formattedDate = useFormattedTimestamp(
searchResult.start_time,
config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p",
config?.ui.timezone,
);
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="bg-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 className="flex flex-col items-start text-xs text-primary-variant">
{searchResult.end_time ? (
<TimeAgo time={searchResult.start_time * 1000} dense />
) : (
<div>
<ActivityIndicator size={24} />
</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>
<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>
</>
);
}

View File

@ -643,6 +643,11 @@ export function CameraGroupEdit({
setIsLoading(true);
let renamingQuery = "";
if (editingGroup && editingGroup[0] !== values.name) {
renamingQuery = `camera_groups.${editingGroup[0]}&`;
}
const order =
editingGroup === undefined
? currentGroups.length + 1
@ -655,9 +660,12 @@ export function CameraGroupEdit({
.join("");
axios
.put(`config/set?${orderQuery}&${iconQuery}${cameraQueries}`, {
requires_restart: 0,
})
.put(
`config/set?${renamingQuery}${orderQuery}&${iconQuery}${cameraQueries}`,
{
requires_restart: 0,
},
)
.then((res) => {
if (res.status === 200) {
toast.success(`Camera group (${values.name}) has been saved.`, {
@ -712,7 +720,6 @@ export function CameraGroupEdit({
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
placeholder="Enter a name..."
disabled={editingGroup !== undefined}
{...field}
/>
</FormControl>

View File

@ -69,6 +69,70 @@ export function CamerasFilterButton({
</Button>
);
const content = (
<CamerasFilterContent
allCameras={allCameras}
groups={groups}
currentCameras={currentCameras}
setCurrentCameras={setCurrentCameras}
setOpen={setOpen}
updateCameraFilter={updateCameraFilter}
/>
);
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentCameras(selectedCameras);
}
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<DropdownMenu
modal={false}
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentCameras(selectedCameras);
}
setOpen(open);
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>{content}</DropdownMenuContent>
</DropdownMenu>
);
}
type CamerasFilterContentProps = {
allCameras: string[];
currentCameras: string[] | undefined;
groups: [string, CameraGroupConfig][];
setCurrentCameras: (cameras: string[] | undefined) => void;
setOpen: (open: boolean) => void;
updateCameraFilter: (cameras: string[] | undefined) => void;
};
export function CamerasFilterContent({
allCameras,
currentCameras,
groups,
setCurrentCameras,
setOpen,
updateCameraFilter,
}: CamerasFilterContentProps) {
return (
<>
{isMobile && (
<>
@ -158,40 +222,4 @@ export function CamerasFilterButton({
</div>
</>
);
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentCameras(selectedCameras);
}
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<DropdownMenu
modal={false}
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentCameras(selectedCameras);
}
setOpen(open);
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>{content}</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,5 +1,4 @@
import { Button } from "../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useEffect, useMemo, useState } from "react";
@ -10,25 +9,19 @@ import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
import FilterSwitch from "./FilterSwitch";
import { FilterList } from "@/types/filter";
import { CalendarRangeFilterButton } from "./CalendarFilterButton";
import { CamerasFilterButton } from "./CamerasFilterButton";
import {
DEFAULT_SEARCH_FILTERS,
SearchFilter,
SearchFilters,
SearchSource,
DEFAULT_TIME_RANGE_AFTER,
DEFAULT_TIME_RANGE_BEFORE,
} from "@/types/search";
import { DateRange } from "react-day-picker";
import { cn } from "@/lib/utils";
import SubFilterIcon from "../icons/SubFilterIcon";
import { FaLocationDot } from "react-icons/fa6";
import { MdLabel } from "react-icons/md";
import SearchSourceIcon from "../icons/SearchSourceIcon";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import { FaArrowRight, FaClock } from "react-icons/fa";
import { useFormattedHour } from "@/hooks/use-date-utils";
import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
import { CalendarRangeFilterButton } from "./CalendarFilterButton";
type SearchFilterGroupProps = {
className: string;
@ -79,8 +72,6 @@ export default function SearchFilterGroup({
return [...labels].sort();
}, [config, filterList, filter]);
const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]);
const allZones = useMemo<string[]>(() => {
if (filterList?.zones) {
return filterList.zones;
@ -159,6 +150,15 @@ export default function SearchFilterGroup({
}}
/>
)}
{filters.includes("general") && (
<GeneralFilterButton
allLabels={filterValues.labels}
selectedLabels={filter?.labels}
updateLabelFilter={(newLabels) => {
onUpdateFilter({ ...filter, labels: newLabels });
}}
/>
)}
{filters.includes("date") && (
<CalendarRangeFilterButton
range={
@ -173,54 +173,12 @@ export default function SearchFilterGroup({
updateSelectedRange={onUpdateSelectedRange}
/>
)}
{filters.includes("time") && (
<TimeRangeFilterButton
config={config}
timeRange={filter?.time_range}
updateTimeRange={(time_range) =>
onUpdateFilter({ ...filter, time_range })
}
/>
)}
{filters.includes("zone") && allZones.length > 0 && (
<ZoneFilterButton
allZones={filterValues.zones}
selectedZones={filter?.zones}
updateZoneFilter={(newZones) =>
onUpdateFilter({ ...filter, zones: newZones })
}
/>
)}
{filters.includes("general") && (
<GeneralFilterButton
allLabels={filterValues.labels}
selectedLabels={filter?.labels}
updateLabelFilter={(newLabels) => {
onUpdateFilter({ ...filter, labels: newLabels });
}}
/>
)}
{filters.includes("sub") && (
<SubFilterButton
allSubLabels={allSubLabels}
selectedSubLabels={filter?.sub_labels}
updateSubLabelFilter={(newSubLabels) =>
onUpdateFilter({ ...filter, sub_labels: newSubLabels })
}
/>
)}
{config?.semantic_search?.enabled &&
filters.includes("source") &&
!filter?.search_type?.includes("similarity") && (
<SearchTypeButton
selectedSearchSources={
filter?.search_type ?? ["thumbnail", "description"]
}
updateSearchSourceFilter={(newSearchSource) =>
onUpdateFilter({ ...filter, search_type: newSearchSource })
}
/>
)}
<SearchFilterDialog
config={config}
filter={filter}
filterValues={filterValues}
onUpdateFilter={onUpdateFilter}
/>
</div>
);
}
@ -295,7 +253,11 @@ function GeneralFilterButton({
<PlatformAwareDialog
trigger={trigger}
content={content}
contentClassName={isDesktop ? "" : "max-h-[75dvh] overflow-hidden p-4"}
contentClassName={
isDesktop
? "scrollbar-container h-auto max-h-[80dvh] overflow-y-auto"
: "max-h-[75dvh] overflow-hidden p-4"
}
open={open}
onOpenChange={(open) => {
if (!open) {
@ -326,7 +288,7 @@ export function GeneralFilterContent({
}: GeneralFilterContentProps) {
return (
<>
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
<div className="overflow-x-hidden">
<div className="mb-5 mt-2.5 flex items-center justify-between">
<Label
className="mx-2 cursor-pointer text-primary"
@ -397,681 +359,3 @@ export function GeneralFilterContent({
</>
);
}
type TimeRangeFilterButtonProps = {
config?: FrigateConfig;
timeRange?: string;
updateTimeRange: (range: string | undefined) => void;
};
function TimeRangeFilterButton({
config,
timeRange,
updateTimeRange,
}: TimeRangeFilterButtonProps) {
const [open, setOpen] = useState(false);
const [startOpen, setStartOpen] = useState(false);
const [endOpen, setEndOpen] = useState(false);
const [afterHour, beforeHour] = useMemo(() => {
if (!timeRange || !timeRange.includes(",")) {
return [DEFAULT_TIME_RANGE_AFTER, DEFAULT_TIME_RANGE_BEFORE];
}
return timeRange.split(",");
}, [timeRange]);
const [selectedAfterHour, setSelectedAfterHour] = useState(afterHour);
const [selectedBeforeHour, setSelectedBeforeHour] = useState(beforeHour);
// format based on locale
const formattedAfter = useFormattedHour(config, afterHour);
const formattedBefore = useFormattedHour(config, beforeHour);
const formattedSelectedAfter = useFormattedHour(config, selectedAfterHour);
const formattedSelectedBefore = useFormattedHour(config, selectedBeforeHour);
useEffect(() => {
setSelectedAfterHour(afterHour);
setSelectedBeforeHour(beforeHour);
// only refresh when state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeRange]);
const trigger = (
<Button
size="sm"
variant={timeRange ? "select" : "default"}
className="flex items-center gap-2 capitalize"
>
<FaClock
className={`${timeRange ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<div
className={`${timeRange ? "text-selected-foreground" : "text-primary"}`}
>
{timeRange ? `${formattedAfter} - ${formattedBefore}` : "All Times"}
</div>
</Button>
);
const content = (
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
<div className="my-5 flex flex-row items-center justify-center gap-2">
<Popover
open={startOpen}
onOpenChange={(open) => {
if (!open) {
setStartOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"} `}
variant={startOpen ? "select" : "default"}
size="sm"
onClick={() => {
setStartOpen(true);
setEndOpen(false);
}}
>
{formattedSelectedAfter}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-row items-center justify-center">
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={selectedAfterHour}
step="60"
onChange={(e) => {
const clock = e.target.value;
const [hour, minute, _] = clock.split(":");
setSelectedAfterHour(`${hour}:${minute}`);
}}
/>
</PopoverContent>
</Popover>
<FaArrowRight className="size-4 text-primary" />
<Popover
open={endOpen}
onOpenChange={(open) => {
if (!open) {
setEndOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
variant={endOpen ? "select" : "default"}
size="sm"
onClick={() => {
setEndOpen(true);
setStartOpen(false);
}}
>
{formattedSelectedBefore}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-center">
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={
selectedBeforeHour == "24:00" ? "23:59" : selectedBeforeHour
}
step="60"
onChange={(e) => {
const clock = e.target.value;
const [hour, minute, _] = clock.split(":");
setSelectedBeforeHour(`${hour}:${minute}`);
}}
/>
</PopoverContent>
</Popover>
</div>
<DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2">
<Button
variant="select"
onClick={() => {
if (
selectedAfterHour == DEFAULT_TIME_RANGE_AFTER &&
selectedBeforeHour == DEFAULT_TIME_RANGE_BEFORE
) {
updateTimeRange(undefined);
} else {
updateTimeRange(`${selectedAfterHour},${selectedBeforeHour}`);
}
setOpen(false);
}}
>
Apply
</Button>
<Button
onClick={() => {
setSelectedAfterHour(DEFAULT_TIME_RANGE_AFTER);
setSelectedBeforeHour(DEFAULT_TIME_RANGE_BEFORE);
updateTimeRange(undefined);
}}
>
Reset
</Button>
</div>
</div>
);
return (
<PlatformAwareDialog
trigger={trigger}
content={content}
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
/>
);
}
type ZoneFilterButtonProps = {
allZones: string[];
selectedZones?: string[];
updateZoneFilter: (zones: string[] | undefined) => void;
};
function ZoneFilterButton({
allZones,
selectedZones,
updateZoneFilter,
}: ZoneFilterButtonProps) {
const [open, setOpen] = useState(false);
const [currentZones, setCurrentZones] = useState<string[] | undefined>(
selectedZones,
);
const buttonText = useMemo(() => {
if (isMobile) {
return "Zones";
}
if (!selectedZones || selectedZones.length == 0) {
return "All Zones";
}
if (selectedZones.length == 1) {
return selectedZones[0];
}
return `${selectedZones.length} Zones`;
}, [selectedZones]);
// ui
useEffect(() => {
setCurrentZones(selectedZones);
// only refresh when state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedZones]);
const trigger = (
<Button
size="sm"
variant={selectedZones?.length ? "select" : "default"}
className="flex items-center gap-2 capitalize"
>
<FaLocationDot
className={`${selectedZones?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<div
className={`${selectedZones?.length ? "text-selected-foreground" : "text-primary"}`}
>
{buttonText}
</div>
</Button>
);
const content = (
<ZoneFilterContent
allZones={allZones}
selectedZones={selectedZones}
currentZones={currentZones}
setCurrentZones={setCurrentZones}
updateZoneFilter={updateZoneFilter}
onClose={() => setOpen(false)}
/>
);
return (
<PlatformAwareDialog
trigger={trigger}
content={content}
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentZones(selectedZones);
}
setOpen(open);
}}
/>
);
}
type ZoneFilterContentProps = {
allZones?: string[];
selectedZones?: string[];
currentZones?: string[];
updateZoneFilter?: (zones: string[] | undefined) => void;
setCurrentZones?: (zones: string[] | undefined) => void;
onClose: () => void;
};
export function ZoneFilterContent({
allZones,
selectedZones,
currentZones,
updateZoneFilter,
setCurrentZones,
onClose,
}: ZoneFilterContentProps) {
return (
<>
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
{allZones && setCurrentZones && (
<>
{isDesktop && <DropdownMenuSeparator />}
<div className="mb-5 mt-2.5 flex items-center justify-between">
<Label
className="mx-2 cursor-pointer text-primary"
htmlFor="allZones"
>
All Zones
</Label>
<Switch
className="ml-1"
id="allZones"
checked={currentZones == undefined}
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentZones(undefined);
}
}}
/>
</div>
<div className="my-2.5 flex flex-col gap-2.5">
{allZones.map((item) => (
<FilterSwitch
key={item}
label={item.replaceAll("_", " ")}
isChecked={currentZones?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {
const updatedZones = currentZones
? [...currentZones]
: [];
updatedZones.push(item);
setCurrentZones(updatedZones);
} else {
const updatedZones = currentZones
? [...currentZones]
: [];
// can not deselect the last item
if (updatedZones.length > 1) {
updatedZones.splice(updatedZones.indexOf(item), 1);
setCurrentZones(updatedZones);
}
}
}}
/>
))}
</div>
</>
)}
</div>
{isDesktop && <DropdownMenuSeparator />}
<div className="flex items-center justify-evenly p-2">
<Button
variant="select"
onClick={() => {
if (updateZoneFilter && selectedZones != currentZones) {
updateZoneFilter(currentZones);
}
onClose();
}}
>
Apply
</Button>
<Button
onClick={() => {
setCurrentZones?.(undefined);
updateZoneFilter?.(undefined);
}}
>
Reset
</Button>
</div>
</>
);
}
type SubFilterButtonProps = {
allSubLabels: string[];
selectedSubLabels: string[] | undefined;
updateSubLabelFilter: (labels: string[] | undefined) => void;
};
function SubFilterButton({
allSubLabels,
selectedSubLabels,
updateSubLabelFilter,
}: SubFilterButtonProps) {
const [open, setOpen] = useState(false);
const [currentSubLabels, setCurrentSubLabels] = useState<
string[] | undefined
>(selectedSubLabels);
const buttonText = useMemo(() => {
if (isMobile) {
return "Sub Labels";
}
if (!selectedSubLabels || selectedSubLabels.length == 0) {
return "All Sub Labels";
}
if (selectedSubLabels.length == 1) {
return selectedSubLabels[0];
}
return `${selectedSubLabels.length} Sub Labels`;
}, [selectedSubLabels]);
const trigger = (
<Button
size="sm"
variant={selectedSubLabels?.length ? "select" : "default"}
className="flex items-center gap-2 capitalize"
>
<SubFilterIcon
className={`${selectedSubLabels?.length || selectedSubLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<div
className={`${selectedSubLabels?.length ? "text-selected-foreground" : "text-primary"}`}
>
{buttonText}
</div>
</Button>
);
const content = (
<SubFilterContent
allSubLabels={allSubLabels}
selectedSubLabels={selectedSubLabels}
currentSubLabels={currentSubLabels}
setCurrentSubLabels={setCurrentSubLabels}
updateSubLabelFilter={updateSubLabelFilter}
onClose={() => setOpen(false)}
/>
);
return (
<PlatformAwareDialog
trigger={trigger}
content={content}
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentSubLabels(selectedSubLabels);
}
setOpen(open);
}}
/>
);
}
type SubFilterContentProps = {
allSubLabels: string[];
selectedSubLabels: string[] | undefined;
currentSubLabels: string[] | undefined;
updateSubLabelFilter: (labels: string[] | undefined) => void;
setCurrentSubLabels: (labels: string[] | undefined) => void;
onClose: () => void;
};
export function SubFilterContent({
allSubLabels,
selectedSubLabels,
currentSubLabels,
updateSubLabelFilter,
setCurrentSubLabels,
onClose,
}: SubFilterContentProps) {
return (
<>
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
<div className="mb-5 mt-2.5 flex items-center justify-between">
<Label
className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels"
>
All Sub Labels
</Label>
<Switch
className="ml-1"
id="allLabels"
checked={currentSubLabels == undefined}
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentSubLabels(undefined);
}
}}
/>
</div>
<div className="my-2.5 flex flex-col gap-2.5">
{allSubLabels.map((item) => (
<FilterSwitch
key={item}
label={item.replaceAll("_", " ")}
isChecked={currentSubLabels?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {
const updatedLabels = currentSubLabels
? [...currentSubLabels]
: [];
updatedLabels.push(item);
setCurrentSubLabels(updatedLabels);
} else {
const updatedLabels = currentSubLabels
? [...currentSubLabels]
: [];
// can not deselect the last item
if (updatedLabels.length > 1) {
updatedLabels.splice(updatedLabels.indexOf(item), 1);
setCurrentSubLabels(updatedLabels);
}
}
}}
/>
))}
</div>
</div>
{isDesktop && <DropdownMenuSeparator />}
<div className="flex items-center justify-evenly p-2">
<Button
variant="select"
onClick={() => {
if (selectedSubLabels != currentSubLabels) {
updateSubLabelFilter(currentSubLabels);
}
onClose();
}}
>
Apply
</Button>
<Button
onClick={() => {
updateSubLabelFilter(undefined);
}}
>
Reset
</Button>
</div>
</>
);
}
type SearchTypeButtonProps = {
selectedSearchSources: SearchSource[] | undefined;
updateSearchSourceFilter: (sources: SearchSource[] | undefined) => void;
};
function SearchTypeButton({
selectedSearchSources,
updateSearchSourceFilter,
}: SearchTypeButtonProps) {
const [open, setOpen] = useState(false);
const buttonText = useMemo(() => {
if (isMobile) {
return "Sources";
}
if (
!selectedSearchSources ||
selectedSearchSources.length == 0 ||
selectedSearchSources.length == 2
) {
return "All Search Sources";
}
if (selectedSearchSources.length == 1) {
return selectedSearchSources[0];
}
return `${selectedSearchSources.length} Search Sources`;
}, [selectedSearchSources]);
const trigger = (
<Button
size="sm"
variant={selectedSearchSources?.length != 2 ? "select" : "default"}
className="flex items-center gap-2 capitalize"
>
<SearchSourceIcon
className={`${selectedSearchSources?.length != 2 ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<div
className={`${selectedSearchSources?.length != 2 ? "text-selected-foreground" : "text-primary"}`}
>
{buttonText}
</div>
</Button>
);
const content = (
<SearchTypeContent
selectedSearchSources={selectedSearchSources}
updateSearchSourceFilter={updateSearchSourceFilter}
onClose={() => setOpen(false)}
/>
);
return (
<PlatformAwareDialog
trigger={trigger}
content={content}
open={open}
onOpenChange={setOpen}
/>
);
}
type SearchTypeContentProps = {
selectedSearchSources: SearchSource[] | undefined;
updateSearchSourceFilter: (sources: SearchSource[] | undefined) => void;
onClose: () => void;
};
export function SearchTypeContent({
selectedSearchSources,
updateSearchSourceFilter,
onClose,
}: SearchTypeContentProps) {
const [currentSearchSources, setCurrentSearchSources] = useState<
SearchSource[] | undefined
>(selectedSearchSources);
return (
<>
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
<div className="my-2.5 flex flex-col gap-2.5">
<FilterSwitch
label="Thumbnail Image"
isChecked={currentSearchSources?.includes("thumbnail") ?? false}
onCheckedChange={(isChecked) => {
const updatedSources = currentSearchSources
? [...currentSearchSources]
: [];
if (isChecked) {
updatedSources.push("thumbnail");
setCurrentSearchSources(updatedSources);
} else {
if (updatedSources.length > 1) {
const index = updatedSources.indexOf("thumbnail");
if (index !== -1) updatedSources.splice(index, 1);
setCurrentSearchSources(updatedSources);
}
}
}}
/>
<FilterSwitch
label="Description"
isChecked={currentSearchSources?.includes("description") ?? false}
onCheckedChange={(isChecked) => {
const updatedSources = currentSearchSources
? [...currentSearchSources]
: [];
if (isChecked) {
updatedSources.push("description");
setCurrentSearchSources(updatedSources);
} else {
if (updatedSources.length > 1) {
const index = updatedSources.indexOf("description");
if (index !== -1) updatedSources.splice(index, 1);
setCurrentSearchSources(updatedSources);
}
}
}}
/>
</div>
{isDesktop && <DropdownMenuSeparator />}
<div className="flex items-center justify-evenly p-2">
<Button
variant="select"
onClick={() => {
if (selectedSearchSources != currentSearchSources) {
updateSearchSourceFilter(currentSearchSources);
}
onClose();
}}
>
Apply
</Button>
<Button
onClick={() => {
updateSearchSourceFilter(undefined);
setCurrentSearchSources(["thumbnail", "description"]);
}}
>
Reset
</Button>
</div>
</div>
</>
);
}

View File

@ -2,7 +2,6 @@ import React, { useState, useRef, useEffect, useCallback } from "react";
import {
LuX,
LuFilter,
LuImage,
LuChevronDown,
LuChevronUp,
LuTrash2,
@ -44,6 +43,7 @@ import {
import { toast } from "sonner";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { MdImageSearch } from "react-icons/md";
type InputWithTagsProps = {
inputFocused: boolean;
@ -514,7 +514,7 @@ export default function InputWithTags({
onFocus={handleInputFocus}
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
className="text-md h-9 pr-24"
className="text-md h-9 pr-32"
placeholder="Search..."
/>
<div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5">
@ -549,7 +549,7 @@ export default function InputWithTags({
{isSimilaritySearch && (
<Tooltip>
<TooltipTrigger className="cursor-default">
<LuImage
<MdImageSearch
aria-label="Similarity search active"
className="size-4 text-selected"
/>

View File

@ -38,6 +38,8 @@ import {
MobilePageTitle,
} from "@/components/mobile/MobilePage";
import { useOverlayState } from "@/hooks/use-overlay-state";
import { DownloadVideoButton } from "@/components/button/DownloadVideoButton";
import { TooltipPortal } from "@radix-ui/react-tooltip";
type ReviewDetailDialogProps = {
review?: ReviewSegment;
@ -143,7 +145,7 @@ export default function ReviewDetailDialog({
<Description className="sr-only">Review item details</Description>
<div
className={cn(
"absolute",
"absolute flex gap-2 lg:flex-col",
isDesktop && "right-1 top-8",
isMobile && "right-0 top-3",
)}
@ -159,7 +161,21 @@ export default function ReviewDetailDialog({
<FaShareAlt className="size-4 text-secondary-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>Share this review item</TooltipContent>
<TooltipPortal>
<TooltipContent>Share this review item</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<DownloadVideoButton
source={`${baseUrl}api/${review.camera}/start/${review.start_time}/end/${review.end_time || Date.now() / 1000}/clip.mp4`}
camera={review.camera}
startTime={review.start_time}
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Download</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</Header>
@ -180,7 +196,7 @@ export default function ReviewDetailDialog({
</div>
</div>
<div className="flex w-full flex-col items-center gap-2">
<div className="flex w-full flex-col gap-1.5">
<div className="flex w-full flex-col gap-1.5 lg:pr-8">
<div className="text-sm text-primary/40">Objects</div>
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm capitalize">
{events?.map((event) => {

View File

@ -69,16 +69,20 @@ const SEARCH_TABS = [
"video",
"object lifecycle",
] as const;
type SearchTab = (typeof SEARCH_TABS)[number];
export type SearchTab = (typeof SEARCH_TABS)[number];
type SearchDetailDialogProps = {
search?: SearchResult;
page: SearchTab;
setSearch: (search: SearchResult | undefined) => void;
setSearchPage: (page: SearchTab) => void;
setSimilarity?: () => void;
};
export default function SearchDetailDialog({
search,
page,
setSearch,
setSearchPage,
setSimilarity,
}: SearchDetailDialogProps) {
const { data: config } = useSWR<FrigateConfig>("config", {
@ -87,15 +91,20 @@ export default function SearchDetailDialog({
// tabs
const [page, setPage] = useState<SearchTab>("details");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
const [pageToggle, setPageToggle] = useOptimisticState(
page,
setSearchPage,
100,
);
// dialog and mobile page
const [isOpen, setIsOpen] = useState(search != undefined);
useEffect(() => {
setIsOpen(search != undefined);
if (search) {
setIsOpen(search != undefined);
}
}, [search]);
const searchTabs = useMemo(() => {
@ -115,12 +124,6 @@ export default function SearchDetailDialog({
views.splice(index, 1);
}
// TODO implement
//if (!config.semantic_search.enabled) {
// const index = views.indexOf("similar-calendar");
// views.splice(index, 1);
// }
return views;
}, [config, search]);
@ -130,9 +133,9 @@ export default function SearchDetailDialog({
}
if (!searchTabs.includes(pageToggle)) {
setPage("details");
setSearchPage("details");
}
}, [pageToggle, searchTabs]);
}, [pageToggle, searchTabs, setSearchPage]);
if (!search) {
return;
@ -147,14 +150,7 @@ export default function SearchDetailDialog({
const Description = isDesktop ? DialogDescription : MobilePageDescription;
return (
<Overlay
open={isOpen}
onOpenChange={(open) => {
if (!open) {
setSearch(undefined);
}
}}
>
<Overlay open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
<Content
className={cn(
"scrollbar-container overflow-y-auto",

View File

@ -1,9 +1,23 @@
import {
MobilePage,
MobilePageContent,
MobilePageHeader,
MobilePageTitle,
} from "@/components/mobile/MobilePage";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { isMobile } from "react-device-detect";
type PlatformAwareDialogProps = {
@ -42,3 +56,60 @@ export default function PlatformAwareDialog({
</Popover>
);
}
type PlatformAwareSheetProps = {
trigger: JSX.Element;
title?: string | JSX.Element;
content: JSX.Element;
triggerClassName?: string;
titleClassName?: string;
contentClassName?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function PlatformAwareSheet({
trigger,
title,
content,
triggerClassName = "",
titleClassName = "",
contentClassName = "",
open,
onOpenChange,
}: PlatformAwareSheetProps) {
if (isMobile) {
return (
<div>
<div onClick={() => onOpenChange(true)}>{trigger}</div>
<MobilePage open={open} onOpenChange={onOpenChange}>
<MobilePageContent className="h-full overflow-hidden">
<MobilePageHeader
className="mx-2"
onClose={() => onOpenChange(false)}
>
<MobilePageTitle>More Filters</MobilePageTitle>
</MobilePageHeader>
<div className={contentClassName}>{content}</div>
</MobilePageContent>
</MobilePage>
</div>
);
}
return (
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
<SheetTrigger asChild className={triggerClassName}>
{trigger}
</SheetTrigger>
<SheetContent className={contentClassName}>
<SheetHeader>
<SheetTitle className={title ? titleClassName : "sr-only"}>
{title ?? ""}
</SheetTitle>
<SheetDescription className="sr-only">Information</SheetDescription>
</SheetHeader>
{content}
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,477 @@
import { FaArrowRight, FaCog } from "react-icons/fa";
import { useEffect, useMemo, useState } from "react";
import { PlatformAwareSheet } from "./PlatformAwareDialog";
import { Button } from "@/components/ui/button";
import useSWR from "swr";
import {
DEFAULT_TIME_RANGE_AFTER,
DEFAULT_TIME_RANGE_BEFORE,
SearchFilter,
SearchSource,
} from "@/types/search";
import { FrigateConfig } from "@/types/frigateConfig";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { isDesktop, isMobileOnly } from "react-device-detect";
import { useFormattedHour } from "@/hooks/use-date-utils";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
type SearchFilterDialogProps = {
config?: FrigateConfig;
filter?: SearchFilter;
filterValues: {
cameras: string[];
labels: string[];
zones: string[];
search_type: SearchSource[];
};
onUpdateFilter: (filter: SearchFilter) => void;
};
export default function SearchFilterDialog({
config,
filter,
filterValues,
onUpdateFilter,
}: SearchFilterDialogProps) {
// data
const [currentFilter, setCurrentFilter] = useState(filter ?? {});
const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]);
// state
const [open, setOpen] = useState(false);
const moreFiltersSelected = useMemo(
() =>
currentFilter &&
(currentFilter.time_range ||
(currentFilter.zones?.length ?? 0) > 0 ||
(currentFilter.sub_labels?.length ?? 0) > 0 ||
(currentFilter.search_type?.length ?? 2) !== 2),
[currentFilter],
);
const trigger = (
<Button
className="flex items-center gap-2"
size="sm"
variant={moreFiltersSelected ? "select" : "default"}
>
<FaCog
className={cn(
moreFiltersSelected ? "text-white" : "text-secondary-foreground",
)}
/>
More Filters
</Button>
);
const content = (
<div className="space-y-3">
<TimeRangeFilterContent
config={config}
timeRange={currentFilter.time_range}
updateTimeRange={(newRange) =>
setCurrentFilter({ ...currentFilter, time_range: newRange })
}
/>
<ZoneFilterContent
allZones={filterValues.zones}
zones={currentFilter.zones}
updateZones={(newZones) =>
setCurrentFilter({ ...currentFilter, zones: newZones })
}
/>
<SubFilterContent
allSubLabels={allSubLabels}
subLabels={currentFilter.sub_labels}
setSubLabels={(newSubLabels) =>
setCurrentFilter({ ...currentFilter, sub_labels: newSubLabels })
}
/>
{config?.semantic_search?.enabled &&
!currentFilter?.search_type?.includes("similarity") && (
<SearchTypeContent
searchSources={
currentFilter?.search_type ?? ["thumbnail", "description"]
}
setSearchSources={(newSearchSource) =>
setCurrentFilter({
...currentFilter,
search_type: newSearchSource,
})
}
/>
)}
{isDesktop && <DropdownMenuSeparator />}
<div className="flex items-center justify-evenly p-2">
<Button
variant="select"
onClick={() => {
if (currentFilter != filter) {
onUpdateFilter(currentFilter);
}
setOpen(false);
}}
>
Apply
</Button>
<Button
onClick={() => {
setCurrentFilter((prevFilter) => ({
...prevFilter,
time_range: undefined,
zones: undefined,
sub_labels: undefined,
search_type: ["thumbnail", "description"],
}));
}}
>
Reset
</Button>
</div>
</div>
);
return (
<PlatformAwareSheet
trigger={trigger}
content={content}
contentClassName={cn(
"w-auto lg:min-w-[275px] scrollbar-container h-full overflow-auto px-4",
isMobileOnly && "pb-20",
)}
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentFilter(filter ?? {});
}
setOpen(open);
}}
/>
);
}
type TimeRangeFilterContentProps = {
config?: FrigateConfig;
timeRange?: string;
updateTimeRange: (range: string | undefined) => void;
};
function TimeRangeFilterContent({
config,
timeRange,
updateTimeRange,
}: TimeRangeFilterContentProps) {
const [startOpen, setStartOpen] = useState(false);
const [endOpen, setEndOpen] = useState(false);
const [afterHour, beforeHour] = useMemo(() => {
if (!timeRange || !timeRange.includes(",")) {
return [DEFAULT_TIME_RANGE_AFTER, DEFAULT_TIME_RANGE_BEFORE];
}
return timeRange.split(",");
}, [timeRange]);
const [selectedAfterHour, setSelectedAfterHour] = useState(afterHour);
const [selectedBeforeHour, setSelectedBeforeHour] = useState(beforeHour);
// format based on locale
const formattedSelectedAfter = useFormattedHour(config, selectedAfterHour);
const formattedSelectedBefore = useFormattedHour(config, selectedBeforeHour);
useEffect(() => {
setSelectedAfterHour(afterHour);
setSelectedBeforeHour(beforeHour);
// only refresh when state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeRange]);
useEffect(() => {
if (
selectedAfterHour == DEFAULT_TIME_RANGE_AFTER &&
selectedBeforeHour == DEFAULT_TIME_RANGE_BEFORE
) {
updateTimeRange(undefined);
} else {
updateTimeRange(`${selectedAfterHour},${selectedBeforeHour}`);
}
// only refresh when state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAfterHour, selectedBeforeHour]);
return (
<div className="overflow-x-hidden">
<div className="text-lg">Time Range</div>
<div className="mt-3 flex flex-row items-center justify-center gap-2">
<Popover
open={startOpen}
onOpenChange={(open) => {
if (!open) {
setStartOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"} `}
variant={startOpen ? "select" : "default"}
size="sm"
onClick={() => {
setStartOpen(true);
setEndOpen(false);
}}
>
{formattedSelectedAfter}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-row items-center justify-center">
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={selectedAfterHour}
step="60"
onChange={(e) => {
const clock = e.target.value;
const [hour, minute, _] = clock.split(":");
setSelectedAfterHour(`${hour}:${minute}`);
}}
/>
</PopoverContent>
</Popover>
<FaArrowRight className="size-4 text-primary" />
<Popover
open={endOpen}
onOpenChange={(open) => {
if (!open) {
setEndOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
variant={endOpen ? "select" : "default"}
size="sm"
onClick={() => {
setEndOpen(true);
setStartOpen(false);
}}
>
{formattedSelectedBefore}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-center">
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={
selectedBeforeHour == "24:00" ? "23:59" : selectedBeforeHour
}
step="60"
onChange={(e) => {
const clock = e.target.value;
const [hour, minute, _] = clock.split(":");
setSelectedBeforeHour(`${hour}:${minute}`);
}}
/>
</PopoverContent>
</Popover>
</div>
</div>
);
}
type ZoneFilterContentProps = {
allZones?: string[];
zones?: string[];
updateZones: (zones: string[] | undefined) => void;
};
export function ZoneFilterContent({
allZones,
zones,
updateZones,
}: ZoneFilterContentProps) {
return (
<>
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<div className="text-lg">Zones</div>
{allZones && (
<>
<div className="mb-5 mt-2.5 flex items-center justify-between">
<Label
className="mx-2 cursor-pointer text-primary"
htmlFor="allZones"
>
All Zones
</Label>
<Switch
className="ml-1"
id="allZones"
checked={zones == undefined}
onCheckedChange={(isChecked) => {
if (isChecked) {
updateZones(undefined);
}
}}
/>
</div>
<div className="mt-2.5 flex flex-col gap-2.5">
{allZones.map((item) => (
<FilterSwitch
key={item}
label={item.replaceAll("_", " ")}
isChecked={zones?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {
const updatedZones = zones ? [...zones] : [];
updatedZones.push(item);
updateZones(updatedZones);
} else {
const updatedZones = zones ? [...zones] : [];
// can not deselect the last item
if (updatedZones.length > 1) {
updatedZones.splice(updatedZones.indexOf(item), 1);
updateZones(updatedZones);
}
}
}}
/>
))}
</div>
</>
)}
</div>
</>
);
}
type SubFilterContentProps = {
allSubLabels: string[];
subLabels: string[] | undefined;
setSubLabels: (labels: string[] | undefined) => void;
};
export function SubFilterContent({
allSubLabels,
subLabels,
setSubLabels,
}: SubFilterContentProps) {
return (
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<div className="text-lg">Sub Labels</div>
<div className="mb-5 mt-2.5 flex items-center justify-between">
<Label className="mx-2 cursor-pointer text-primary" htmlFor="allLabels">
All Sub Labels
</Label>
<Switch
className="ml-1"
id="allLabels"
checked={subLabels == undefined}
onCheckedChange={(isChecked) => {
if (isChecked) {
setSubLabels(undefined);
}
}}
/>
</div>
<div className="mt-2.5 flex flex-col gap-2.5">
{allSubLabels.map((item) => (
<FilterSwitch
key={item}
label={item.replaceAll("_", " ")}
isChecked={subLabels?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {
const updatedLabels = subLabels ? [...subLabels] : [];
updatedLabels.push(item);
setSubLabels(updatedLabels);
} else {
const updatedLabels = subLabels ? [...subLabels] : [];
// can not deselect the last item
if (updatedLabels.length > 1) {
updatedLabels.splice(updatedLabels.indexOf(item), 1);
setSubLabels(updatedLabels);
}
}
}}
/>
))}
</div>
</div>
);
}
type SearchTypeContentProps = {
searchSources: SearchSource[] | undefined;
setSearchSources: (sources: SearchSource[] | undefined) => void;
};
export function SearchTypeContent({
searchSources,
setSearchSources,
}: SearchTypeContentProps) {
return (
<>
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<div className="text-lg">Search Sources</div>
<div className="mt-2.5 flex flex-col gap-2.5">
<FilterSwitch
label="Thumbnail Image"
isChecked={searchSources?.includes("thumbnail") ?? false}
onCheckedChange={(isChecked) => {
const updatedSources = searchSources ? [...searchSources] : [];
if (isChecked) {
updatedSources.push("thumbnail");
setSearchSources(updatedSources);
} else {
if (updatedSources.length > 1) {
const index = updatedSources.indexOf("thumbnail");
if (index !== -1) updatedSources.splice(index, 1);
setSearchSources(updatedSources);
}
}
}}
/>
<FilterSwitch
label="Description"
isChecked={searchSources?.includes("description") ?? false}
onCheckedChange={(isChecked) => {
const updatedSources = searchSources ? [...searchSources] : [];
if (isChecked) {
updatedSources.push("description");
setSearchSources(updatedSources);
} else {
if (updatedSources.length > 1) {
const index = updatedSources.indexOf("description");
if (index !== -1) updatedSources.splice(index, 1);
setSearchSources(updatedSources);
}
}
}}
/>
</div>
</div>
</>
);
}

View File

@ -275,7 +275,7 @@ export default function Explore() {
<TbExclamationCircle className="mb-3 size-10" />
<div>Search Unavailable</div>
</div>
{embeddingsReindexing && (
{embeddingsReindexing && allModelsLoaded && (
<>
<div className="text-center text-primary-variant">
Search can be used after tracked object embeddings have
@ -384,6 +384,7 @@ export default function Explore() {
searchFilter={searchFilter}
searchResults={searchResults}
isLoading={(isLoadingInitialData || isLoadingMore) ?? true}
hasMore={!isReachingEnd}
setSearch={setSearch}
setSimilaritySearch={(search) => {
setSearchFilter({
@ -395,7 +396,7 @@ export default function Explore() {
setSearchFilter={setSearchFilter}
onUpdateFilter={setSearchFilter}
loadMore={loadMore}
hasMore={!isReachingEnd}
refresh={mutate}
/>
)}
</>

View File

@ -29,16 +29,18 @@ import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
import { PolygonType } from "@/types/canvas";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import scrollIntoView from "scroll-into-view-if-needed";
import GeneralSettingsView from "@/views/settings/GeneralSettingsView";
import CameraSettingsView from "@/views/settings/CameraSettingsView";
import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
import MotionTunerView from "@/views/settings/MotionTunerView";
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
import AuthenticationView from "@/views/settings/AuthenticationView";
import NotificationView from "@/views/settings/NotificationsSettingsView";
import SearchSettingsView from "@/views/settings/SearchSettingsView";
import UiSettingsView from "@/views/settings/UiSettingsView";
const allSettingsViews = [
"general",
"UI settings",
"search settings",
"camera settings",
"masks / zones",
"motion tuner",
@ -49,7 +51,7 @@ const allSettingsViews = [
type SettingsType = (typeof allSettingsViews)[number];
export default function Settings() {
const [page, setPage] = useState<SettingsType>("general");
const [page, setPage] = useState<SettingsType>("UI settings");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
const tabsRef = useRef<HTMLDivElement | null>(null);
@ -140,7 +142,7 @@ export default function Settings() {
{Object.values(settingsViews).map((item) => (
<ToggleGroupItem
key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "general" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "UI settings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
aria-label={`Select ${item}`}
@ -172,7 +174,10 @@ export default function Settings() {
)}
</div>
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
{page == "general" && <GeneralSettingsView />}
{page == "UI settings" && <UiSettingsView />}
{page == "search settings" && (
<SearchSettingsView setUnsavedChanges={setUnsavedChanges} />
)}
{page == "debug" && (
<ObjectSettingsView selectedCamera={selectedCamera} />
)}

View File

@ -27,6 +27,8 @@ export const ATTRIBUTE_LABELS = [
"ups",
];
export type SearchModelSize = "small" | "large";
export interface CameraConfig {
audio: {
enabled: boolean;
@ -340,6 +342,7 @@ export interface FrigateConfig {
path: string | null;
width: number;
colormap: { [key: string]: [number, number, number] };
attributes_map: { [key: string]: [string] };
};
motion: Record<string, unknown> | null;
@ -417,7 +420,8 @@ export interface FrigateConfig {
semantic_search: {
enabled: boolean;
model_size: string;
reindex: boolean;
model_size: SearchModelSize;
};
snapshots: {

View File

@ -1,8 +1,9 @@
import SearchThumbnail from "@/components/card/SearchThumbnail";
import SearchFilterGroup from "@/components/filter/SearchFilterGroup";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import Chip from "@/components/indicators/Chip";
import SearchDetailDialog from "@/components/overlay/detail/SearchDetailDialog";
import SearchDetailDialog, {
SearchTab,
} from "@/components/overlay/detail/SearchDetailDialog";
import { Toaster } from "@/components/ui/sonner";
import {
Tooltip,
@ -14,7 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { SearchFilter, SearchResult, SearchSource } from "@/types/search";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isDesktop, isMobileOnly } from "react-device-detect";
import { LuColumns, LuImage, LuSearchX, LuText } from "react-icons/lu";
import { LuColumns, LuSearchX } from "react-icons/lu";
import useSWR from "swr";
import ExploreView from "../explore/ExploreView";
import useKeyboardListener, {
@ -25,7 +26,6 @@ import InputWithTags from "@/components/input/InputWithTags";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { isEqual } from "lodash";
import { formatDateToLocaleString } from "@/utils/dateUtil";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { Slider } from "@/components/ui/slider";
import {
Popover,
@ -33,6 +33,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { usePersistence } from "@/hooks/use-persistence";
import SearchThumbnailFooter from "@/components/card/SearchThumbnailFooter";
type SearchViewProps = {
search: string;
@ -40,12 +41,13 @@ type SearchViewProps = {
searchFilter?: SearchFilter;
searchResults?: SearchResult[];
isLoading: boolean;
hasMore: boolean;
setSearch: (search: string) => void;
setSimilaritySearch: (search: SearchResult) => void;
setSearchFilter: (filter: SearchFilter) => void;
onUpdateFilter: (filter: SearchFilter) => void;
loadMore: () => void;
hasMore: boolean;
refresh: () => void;
};
export default function SearchView({
search,
@ -53,12 +55,13 @@ export default function SearchView({
searchFilter,
searchResults,
isLoading,
hasMore,
setSearch,
setSimilaritySearch,
setSearchFilter,
onUpdateFilter,
loadMore,
hasMore,
refresh,
}: SearchViewProps) {
const contentRef = useRef<HTMLDivElement | null>(null);
const { data: config } = useSWR<FrigateConfig>("config", {
@ -70,15 +73,17 @@ export default function SearchView({
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
const effectiveColumnCount = useMemo(() => columnCount ?? 4, [columnCount]);
const gridClassName = cn("grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2", {
"sm:grid-cols-2": effectiveColumnCount <= 2,
"sm:grid-cols-3": effectiveColumnCount === 3,
"sm:grid-cols-4": effectiveColumnCount === 4,
"sm:grid-cols-5": effectiveColumnCount === 5,
"sm:grid-cols-6": effectiveColumnCount === 6,
"sm:grid-cols-7": effectiveColumnCount === 7,
"sm:grid-cols-8": effectiveColumnCount >= 8,
});
const gridClassName = cn(
"grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2",
isMobileOnly && "grid-cols-2",
{
"sm:grid-cols-2": effectiveColumnCount <= 2,
"sm:grid-cols-3": effectiveColumnCount === 3,
"sm:grid-cols-4": effectiveColumnCount === 4,
"sm:grid-cols-5": effectiveColumnCount === 5,
"sm:grid-cols-6": effectiveColumnCount === 6,
},
);
// suggestions values
@ -161,16 +166,25 @@ export default function SearchView({
// detail
const [searchDetail, setSearchDetail] = useState<SearchResult>();
const [page, setPage] = useState<SearchTab>("details");
// search interaction
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const onSelectSearch = useCallback((item: SearchResult, index: number) => {
setSearchDetail(item);
setSelectedIndex(index);
}, []);
const onSelectSearch = useCallback(
(item: SearchResult, index: number, page: SearchTab = "details") => {
setPage(page);
setSearchDetail(item);
setSelectedIndex(index);
},
[],
);
useEffect(() => {
setSelectedIndex(0);
}, [searchTerm, searchFilter]);
// update search detail when results change
@ -187,21 +201,6 @@ export default function SearchView({
}
}, [searchResults, searchDetail]);
// confidence score
const zScoreToConfidence = (score: number) => {
// Normalizing is not needed for similarity searches
// Sigmoid function for normalized: 1 / (1 + e^x)
// Cosine for similarity
if (searchFilter) {
const notNormalized = searchFilter?.search_type?.includes("similarity");
const confidence = notNormalized ? 1 - score : 1 / (1 + Math.exp(score));
return Math.round(confidence * 100);
}
};
const hasExistingSearch = useMemo(
() => searchResults != undefined || searchFilter != undefined,
[searchResults, searchFilter],
@ -310,7 +309,9 @@ export default function SearchView({
<Toaster closeButton={true} />
<SearchDetailDialog
search={searchDetail}
page={page}
setSearch={setSearchDetail}
setSearchPage={setPage}
setSimilarity={
searchDetail && (() => setSimilaritySearch(searchDetail))
}
@ -388,47 +389,31 @@ export default function SearchView({
>
<div
className={cn(
"aspect-square size-full overflow-hidden rounded-lg",
"aspect-square w-full overflow-hidden rounded-t-lg border",
)}
>
<SearchThumbnail
searchResult={value}
onClick={() => onSelectSearch(value, index)}
/>
</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"}`}
/>
<div className="flex w-full items-center justify-between rounded-b-lg border border-t-0 bg-card p-3 text-card-foreground">
<SearchThumbnailFooter
searchResult={value}
findSimilar={() => {
if (config?.semantic_search.enabled) {
setSimilaritySearch(value);
}
}}
onClick={() => onSelectSearch(value, index)}
refreshResults={refresh}
showObjectLifecycle={() =>
onSelectSearch(value, index, "object lifecycle")
}
/>
{(searchTerm ||
searchFilter?.search_type?.includes("similarity")) && (
<div className={cn("absolute right-2 top-2 z-40")}>
<Tooltip>
<TooltipTrigger>
<Chip
className={`flex select-none items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize text-white`}
>
{value.search_source == "thumbnail" ? (
<LuImage className="mr-1 size-3" />
) : (
<LuText className="mr-1 size-3" />
)}
{zScoreToConfidence(value.search_distance)}%
</Chip>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
Matched {value.search_source} at{" "}
{zScoreToConfidence(value.search_distance)}%
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
)}
</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"}`}
/>
</div>
);
})}
@ -467,7 +452,7 @@ export default function SearchView({
<Slider
value={[effectiveColumnCount]}
onValueChange={([value]) => setColumnCount(value)}
max={8}
max={6}
min={2}
step={1}
className="flex-grow"

View File

@ -0,0 +1,288 @@
import Heading from "@/components/ui/heading";
import { FrigateConfig, SearchModelSize } from "@/types/frigateConfig";
import useSWR from "swr";
import axios from "axios";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useCallback, useContext, useEffect, useState } from "react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { Separator } from "@/components/ui/separator";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
type SearchSettingsViewProps = {
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
};
type SearchSettings = {
enabled?: boolean;
reindex?: boolean;
model_size?: SearchModelSize;
};
export default function SearchSettingsView({
setUnsavedChanges,
}: SearchSettingsViewProps) {
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [changedValue, setChangedValue] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
const [searchSettings, setSearchSettings] = useState<SearchSettings>({
enabled: undefined,
reindex: undefined,
model_size: undefined,
});
const [origSearchSettings, setOrigSearchSettings] = useState<SearchSettings>({
enabled: undefined,
reindex: undefined,
model_size: undefined,
});
useEffect(() => {
if (config) {
if (searchSettings?.enabled == undefined) {
setSearchSettings({
enabled: config.semantic_search.enabled,
reindex: config.semantic_search.reindex,
model_size: config.semantic_search.model_size,
});
}
setOrigSearchSettings({
enabled: config.semantic_search.enabled,
reindex: config.semantic_search.reindex,
model_size: config.semantic_search.model_size,
});
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
const handleSearchConfigChange = (newConfig: Partial<SearchSettings>) => {
setSearchSettings((prevConfig) => ({ ...prevConfig, ...newConfig }));
setUnsavedChanges(true);
setChangedValue(true);
};
const saveToConfig = useCallback(async () => {
setIsLoading(true);
axios
.put(
`config/set?semantic_search.enabled=${searchSettings.enabled}&semantic_search.reindex=${searchSettings.reindex}&semantic_search.model_size=${searchSettings.model_size}`,
)
.then((res) => {
if (res.status === 200) {
toast.success("Search settings have been saved.", {
position: "top-center",
});
setChangedValue(false);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
}, [
updateConfig,
searchSettings.enabled,
searchSettings.reindex,
searchSettings.model_size,
]);
const onCancel = useCallback(() => {
setSearchSettings(origSearchSettings);
setChangedValue(false);
removeMessage("search_settings", "search_settings");
}, [origSearchSettings, removeMessage]);
useEffect(() => {
if (changedValue) {
addMessage(
"search_settings",
`Unsaved search settings changes`,
undefined,
"search_settings",
);
} else {
removeMessage("search_settings", "search_settings");
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [changedValue]);
useEffect(() => {
document.title = "Search Settings - Frigate";
}, []);
if (!config) {
return <ActivityIndicator />;
}
return (
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
<Heading as="h3" className="my-2">
Search Settings
</Heading>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Semantic Search
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
Semantic Search in Frigate allows you to find tracked objects
within your review items using either the image itself, a
user-defined text description, or an automatically generated one.
</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/semantic_search"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Read the Documentation
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<div className="flex w-full max-w-lg flex-col space-y-6">
<div className="flex flex-row items-center">
<Switch
id="enabled"
className="mr-3"
disabled={searchSettings.enabled === undefined}
checked={searchSettings.enabled === true}
onCheckedChange={(isChecked) => {
handleSearchConfigChange({ enabled: isChecked });
}}
/>
<div className="space-y-0.5">
<Label htmlFor="enabled">Enabled</Label>
</div>
</div>
<div className="flex flex-col">
<div className="flex flex-row items-center">
<Switch
id="reindex"
className="mr-3"
disabled={searchSettings.reindex === undefined}
checked={searchSettings.reindex === true}
onCheckedChange={(isChecked) => {
handleSearchConfigChange({ reindex: isChecked });
}}
/>
<div className="space-y-0.5">
<Label htmlFor="reindex">Re-Index On Startup</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
Re-indexing will reprocess all thumbnails and descriptions (if
enabled) and apply the embeddings on each startup.{" "}
<em>Don't forget to disable the option after restarting!</em>
</div>
</div>
<div className="mt-2 flex flex-col space-y-6">
<div className="space-y-0.5">
<div className="text-md">Model Size</div>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
The size of the model used for semantic search embeddings.
</p>
<ul className="list-disc pl-5 text-sm">
<li>
Using <em>small</em> employs a quantized version of the
model that uses less RAM and runs faster on CPU with a very
negligible difference in embedding quality.
</li>
<li>
Using <em>large</em> employs the full Jina model and will
automatically run on the GPU if applicable.
</li>
</ul>
</div>
</div>
<Select
value={searchSettings.model_size}
onValueChange={(value) =>
handleSearchConfigChange({
model_size: value as SearchModelSize,
})
}
>
<SelectTrigger className="w-20">
{searchSettings.model_size}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{["small", "large"].map((size) => (
<SelectItem
key={size}
className="cursor-pointer"
value={size}
>
{size}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<Separator className="my-2 flex bg-secondary" />
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button className="flex flex-1" onClick={onCancel}>
Reset
</Button>
<Button
variant="select"
disabled={!changedValue || isLoading}
className="flex flex-1"
onClick={saveToConfig}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
</div>
) : (
"Save"
)}
</Button>
</div>
</div>
</div>
);
}

View File

@ -22,7 +22,7 @@ import {
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
const WEEK_STARTS_ON = ["Sunday", "Monday"];
export default function GeneralSettingsView() {
export default function UiSettingsView() {
const { data: config } = useSWR<FrigateConfig>("config");
const clearStoredLayouts = useCallback(() => {