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.username: body.username,
User.password_hash: password_hash, User.password_hash: password_hash,
User.notification_tokens: [],
} }
).execute() ).execute()
return JSONResponse(content={"username": body.username}) return JSONResponse(content={"username": body.username})

View File

@ -7,6 +7,7 @@ import os
import subprocess as sp import subprocess as sp
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path as FilePath
from urllib.parse import unquote from urllib.parse import unquote
import cv2 import cv2
@ -450,8 +451,27 @@ def recording_clip(
camera_name: str, camera_name: str,
start_ts: float, start_ts: float,
end_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 = (
Recordings.select( Recordings.select(
Recordings.path, Recordings.path,
@ -467,18 +487,18 @@ def recording_clip(
.order_by(Recordings.start_time.asc()) .order_by(Recordings.start_time.asc())
) )
playlist_lines = [] file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt")
clip: Recordings file_path = f"/tmp/cache/{file_name}"
for clip in recordings: with open(file_path, "w") as file:
playlist_lines.append(f"file '{clip.path}'") clip: Recordings
# if this is the starting clip, add an inpoint for clip in recordings:
if clip.start_time < start_ts: file.write(f"file '{clip.path}'\n")
playlist_lines.append(f"inpoint {int(start_ts - clip.start_time)}") # if this is the starting clip, add an inpoint
# if this is the ending clip, add an outpoint if clip.start_time < start_ts:
if clip.end_time > end_ts: file.write(f"inpoint {int(start_ts - clip.start_time)}\n")
playlist_lines.append(f"outpoint {int(end_ts - clip.start_time)}") # if this is the ending clip, add an outpoint
if clip.end_time > end_ts:
file_name = sanitize_filename(f"clip_{camera_name}_{start_ts}-{end_ts}.mp4") file.write(f"outpoint {int(end_ts - clip.start_time)}\n")
if len(file_name) > 1000: if len(file_name) > 1000:
return JSONResponse( return JSONResponse(
@ -489,67 +509,32 @@ def recording_clip(
status_code=403, status_code=403,
) )
path = os.path.join(CLIPS_DIR, f"cache/{file_name}")
config: FrigateConfig = request.app.frigate_config config: FrigateConfig = request.app.frigate_config
if not os.path.exists(path): ffmpeg_cmd = [
ffmpeg_cmd = [ config.ffmpeg.ffmpeg_path,
config.ffmpeg.ffmpeg_path, "-hide_banner",
"-hide_banner", "-y",
"-y", "-protocol_whitelist",
"-protocol_whitelist", "pipe,file",
"pipe,file", "-f",
"-f", "concat",
"concat", "-safe",
"-safe", "0",
"0", "-i",
"-i", file_path,
"/dev/stdin", "-c",
"-c", "copy",
"copy", "-movflags",
"-movflags", "frag_keyframe+empty_moov",
"+faststart", "-f",
path, "mp4",
] "pipe:",
p = sp.run( ]
ffmpeg_cmd,
input="\n".join(playlist_lines),
encoding="ascii",
capture_output=True,
)
if p.returncode != 0: return StreamingResponse(
logger.error(p.stderr) run_download(ffmpeg_cmd, file_path),
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,
media_type="video/mp4", 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") @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: try:
event: Event = Event.get(Event.id == event_id) event: Event = Event.get(Event.id == event_id)
except DoesNotExist: except DoesNotExist:
@ -1048,7 +1033,7 @@ def event_clip(request: Request, event_id: str, download: bool = False):
end_ts = ( end_ts = (
datetime.now().timestamp() if event.end_time is None else event.end_time 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 = { headers = {
"Content-Description": "File Transfer", "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}", "X-Accel-Redirect": f"/clips/{file_name}",
} }
if download:
headers["Content-Disposition"] = "attachment; filename=%s" % file_name
return FileResponse( return FileResponse(
clip_path, clip_path,
media_type="video/mp4", media_type="video/mp4",

View File

@ -174,7 +174,12 @@ class Embeddings:
return embedding return embedding
def batch_upsert_description(self, event_descriptions: dict[str, str]) -> ndarray: 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()) ids = list(event_descriptions.keys())
items = [] items = []

View File

@ -4,6 +4,8 @@ import importlib
import os import os
from typing import Optional from typing import Optional
from playhouse.shortcuts import model_to_dict
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
from frigate.models import Event from frigate.models import Event
@ -36,8 +38,9 @@ class GenAIClient:
) -> Optional[str]: ) -> Optional[str]:
"""Generate a description for the frame.""" """Generate a description for the frame."""
prompt = camera_config.genai.object_prompts.get( prompt = camera_config.genai.object_prompts.get(
event.label, camera_config.genai.prompt event.label,
).format(**event) camera_config.genai.prompt,
).format(**model_to_dict(event))
return self._send(prompt, thumbnails) return self._send(prompt, thumbnails)
def _init_provider(self): 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: if processed_segment_count > keep_count:
logger.warning( 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..." 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) self.end_time_cache.pop(cache_path, None)
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:] 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 = [] tasks = []
for camera, recordings in grouped_recordings.items(): for camera, recordings in grouped_recordings.items():
# clear out all the object recording info for old frames # 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) update_yaml_file(file_path, key_path, new_value_list)
else: else:
value = new_value_list[0] value = new_value_list[0]
if "," in value: try:
# Skip conversion if we're a mask or zone string # no need to convert if we have a mask/zone string
update_yaml_file(file_path, key_path, value) value = ast.literal_eval(value) if "," not in value else value
else: except (ValueError, SyntaxError):
try: pass
value = ast.literal_eval(value)
except (ValueError, SyntaxError):
pass
update_yaml_file(file_path, key_path, value)
update_yaml_file(file_path, key_path, value) 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 { useApiHost } from "@/api";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import TimeAgo from "../dynamic/TimeAgo";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { isIOS, isSafari } from "react-device-detect"; import { isIOS, isSafari } from "react-device-detect";
import Chip from "@/components/indicators/Chip"; import Chip from "@/components/indicators/Chip";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import useImageLoaded from "@/hooks/use-image-loaded"; import useImageLoaded from "@/hooks/use-image-loaded";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator"; import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
import ActivityIndicator from "../indicators/activity-indicator";
import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { SearchResult } from "@/types/search"; import { SearchResult } from "@/types/search";
import useContextMenu from "@/hooks/use-contextmenu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
type SearchThumbnailProps = { type SearchThumbnailProps = {
searchResult: SearchResult; searchResult: SearchResult;
findSimilar: () => void;
onClick: (searchResult: SearchResult) => void; onClick: (searchResult: SearchResult) => void;
}; };
export default function SearchThumbnail({ export default function SearchThumbnail({
searchResult, searchResult,
findSimilar,
onClick, onClick,
}: SearchThumbnailProps) { }: SearchThumbnailProps) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
useContextMenu(imgRef, findSimilar); // interactions
const handleOnClick = useCallback(() => { const handleOnClick = useCallback(() => {
onClick(searchResult); onClick(searchResult);
}, [searchResult, onClick]); }, [searchResult, onClick]);
// date const objectLabel = useMemo(() => {
if (
!config ||
!searchResult.sub_label ||
!config.model.attributes_map[searchResult.label]
) {
return searchResult.label;
}
const formattedDate = useFormattedTimestamp( if (
searchResult.start_time, config.model.attributes_map[searchResult.label].includes(
config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", searchResult.sub_label,
config?.ui.timezone, )
); ) {
return searchResult.sub_label;
}
return `${searchResult.label}-verified`;
}, [config, searchResult]);
return ( return (
<div className="relative size-full cursor-pointer" onClick={handleOnClick}> <div className="relative size-full cursor-pointer" onClick={handleOnClick}>
@ -80,17 +86,21 @@ export default function SearchThumbnail({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="mx-3 pb-1 text-sm text-white"> <div className="mx-3 pb-1 text-sm text-white">
<Chip <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)} 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> </Chip>
</div> </div>
</TooltipTrigger> </TooltipTrigger>
</div> </div>
<TooltipPortal> <TooltipPortal>
<TooltipContent className="capitalize"> <TooltipContent className="capitalize">
{[...new Set([searchResult.label])] {[objectLabel]
.filter( .filter(
(item) => item !== undefined && !item.includes("-verified"), (item) => item !== undefined && !item.includes("-verified"),
) )
@ -103,18 +113,7 @@ export default function SearchThumbnail({
</Tooltip> </Tooltip>
</div> </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-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="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 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> </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); setIsLoading(true);
let renamingQuery = "";
if (editingGroup && editingGroup[0] !== values.name) {
renamingQuery = `camera_groups.${editingGroup[0]}&`;
}
const order = const order =
editingGroup === undefined editingGroup === undefined
? currentGroups.length + 1 ? currentGroups.length + 1
@ -655,9 +660,12 @@ export function CameraGroupEdit({
.join(""); .join("");
axios axios
.put(`config/set?${orderQuery}&${iconQuery}${cameraQueries}`, { .put(
requires_restart: 0, `config/set?${renamingQuery}${orderQuery}&${iconQuery}${cameraQueries}`,
}) {
requires_restart: 0,
},
)
.then((res) => { .then((res) => {
if (res.status === 200) { if (res.status === 200) {
toast.success(`Camera group (${values.name}) has been saved.`, { toast.success(`Camera group (${values.name}) has been saved.`, {
@ -712,7 +720,6 @@ export function CameraGroupEdit({
<Input <Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" 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..." placeholder="Enter a name..."
disabled={editingGroup !== undefined}
{...field} {...field}
/> />
</FormControl> </FormControl>

View File

@ -69,6 +69,70 @@ export function CamerasFilterButton({
</Button> </Button>
); );
const content = ( 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 && ( {isMobile && (
<> <>
@ -158,40 +222,4 @@ export function CamerasFilterButton({
</div> </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 { Button } from "../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
@ -10,25 +9,19 @@ import { Switch } from "../ui/switch";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import FilterSwitch from "./FilterSwitch"; import FilterSwitch from "./FilterSwitch";
import { FilterList } from "@/types/filter"; import { FilterList } from "@/types/filter";
import { CalendarRangeFilterButton } from "./CalendarFilterButton";
import { CamerasFilterButton } from "./CamerasFilterButton"; import { CamerasFilterButton } from "./CamerasFilterButton";
import { import {
DEFAULT_SEARCH_FILTERS, DEFAULT_SEARCH_FILTERS,
SearchFilter, SearchFilter,
SearchFilters, SearchFilters,
SearchSource, SearchSource,
DEFAULT_TIME_RANGE_AFTER,
DEFAULT_TIME_RANGE_BEFORE,
} from "@/types/search"; } from "@/types/search";
import { DateRange } from "react-day-picker"; import { DateRange } from "react-day-picker";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import SubFilterIcon from "../icons/SubFilterIcon";
import { FaLocationDot } from "react-icons/fa6";
import { MdLabel } from "react-icons/md"; import { MdLabel } from "react-icons/md";
import SearchSourceIcon from "../icons/SearchSourceIcon";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import { FaArrowRight, FaClock } from "react-icons/fa"; import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
import { useFormattedHour } from "@/hooks/use-date-utils"; import { CalendarRangeFilterButton } from "./CalendarFilterButton";
type SearchFilterGroupProps = { type SearchFilterGroupProps = {
className: string; className: string;
@ -79,8 +72,6 @@ export default function SearchFilterGroup({
return [...labels].sort(); return [...labels].sort();
}, [config, filterList, filter]); }, [config, filterList, filter]);
const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]);
const allZones = useMemo<string[]>(() => { const allZones = useMemo<string[]>(() => {
if (filterList?.zones) { if (filterList?.zones) {
return 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") && ( {filters.includes("date") && (
<CalendarRangeFilterButton <CalendarRangeFilterButton
range={ range={
@ -173,54 +173,12 @@ export default function SearchFilterGroup({
updateSelectedRange={onUpdateSelectedRange} updateSelectedRange={onUpdateSelectedRange}
/> />
)} )}
{filters.includes("time") && ( <SearchFilterDialog
<TimeRangeFilterButton config={config}
config={config} filter={filter}
timeRange={filter?.time_range} filterValues={filterValues}
updateTimeRange={(time_range) => onUpdateFilter={onUpdateFilter}
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 })
}
/>
)}
</div> </div>
); );
} }
@ -295,7 +253,11 @@ function GeneralFilterButton({
<PlatformAwareDialog <PlatformAwareDialog
trigger={trigger} trigger={trigger}
content={content} 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} open={open}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
@ -326,7 +288,7 @@ export function GeneralFilterContent({
}: GeneralFilterContentProps) { }: GeneralFilterContentProps) {
return ( 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"> <div className="mb-5 mt-2.5 flex items-center justify-between">
<Label <Label
className="mx-2 cursor-pointer text-primary" 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 { import {
LuX, LuX,
LuFilter, LuFilter,
LuImage,
LuChevronDown, LuChevronDown,
LuChevronUp, LuChevronUp,
LuTrash2, LuTrash2,
@ -44,6 +43,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { MdImageSearch } from "react-icons/md";
type InputWithTagsProps = { type InputWithTagsProps = {
inputFocused: boolean; inputFocused: boolean;
@ -514,7 +514,7 @@ export default function InputWithTags({
onFocus={handleInputFocus} onFocus={handleInputFocus}
onBlur={handleInputBlur} onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
className="text-md h-9 pr-24" className="text-md h-9 pr-32"
placeholder="Search..." placeholder="Search..."
/> />
<div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5"> <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 && ( {isSimilaritySearch && (
<Tooltip> <Tooltip>
<TooltipTrigger className="cursor-default"> <TooltipTrigger className="cursor-default">
<LuImage <MdImageSearch
aria-label="Similarity search active" aria-label="Similarity search active"
className="size-4 text-selected" className="size-4 text-selected"
/> />

View File

@ -38,6 +38,8 @@ import {
MobilePageTitle, MobilePageTitle,
} from "@/components/mobile/MobilePage"; } from "@/components/mobile/MobilePage";
import { useOverlayState } from "@/hooks/use-overlay-state"; import { useOverlayState } from "@/hooks/use-overlay-state";
import { DownloadVideoButton } from "@/components/button/DownloadVideoButton";
import { TooltipPortal } from "@radix-ui/react-tooltip";
type ReviewDetailDialogProps = { type ReviewDetailDialogProps = {
review?: ReviewSegment; review?: ReviewSegment;
@ -143,7 +145,7 @@ export default function ReviewDetailDialog({
<Description className="sr-only">Review item details</Description> <Description className="sr-only">Review item details</Description>
<div <div
className={cn( className={cn(
"absolute", "absolute flex gap-2 lg:flex-col",
isDesktop && "right-1 top-8", isDesktop && "right-1 top-8",
isMobile && "right-0 top-3", isMobile && "right-0 top-3",
)} )}
@ -159,7 +161,21 @@ export default function ReviewDetailDialog({
<FaShareAlt className="size-4 text-secondary-foreground" /> <FaShareAlt className="size-4 text-secondary-foreground" />
</Button> </Button>
</TooltipTrigger> </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> </Tooltip>
</div> </div>
</Header> </Header>
@ -180,7 +196,7 @@ export default function ReviewDetailDialog({
</div> </div>
</div> </div>
<div className="flex w-full flex-col items-center gap-2"> <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="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"> <div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm capitalize">
{events?.map((event) => { {events?.map((event) => {

View File

@ -69,16 +69,20 @@ const SEARCH_TABS = [
"video", "video",
"object lifecycle", "object lifecycle",
] as const; ] as const;
type SearchTab = (typeof SEARCH_TABS)[number]; export type SearchTab = (typeof SEARCH_TABS)[number];
type SearchDetailDialogProps = { type SearchDetailDialogProps = {
search?: SearchResult; search?: SearchResult;
page: SearchTab;
setSearch: (search: SearchResult | undefined) => void; setSearch: (search: SearchResult | undefined) => void;
setSearchPage: (page: SearchTab) => void;
setSimilarity?: () => void; setSimilarity?: () => void;
}; };
export default function SearchDetailDialog({ export default function SearchDetailDialog({
search, search,
page,
setSearch, setSearch,
setSearchPage,
setSimilarity, setSimilarity,
}: SearchDetailDialogProps) { }: SearchDetailDialogProps) {
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
@ -87,15 +91,20 @@ export default function SearchDetailDialog({
// tabs // tabs
const [page, setPage] = useState<SearchTab>("details"); const [pageToggle, setPageToggle] = useOptimisticState(
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); page,
setSearchPage,
100,
);
// dialog and mobile page // dialog and mobile page
const [isOpen, setIsOpen] = useState(search != undefined); const [isOpen, setIsOpen] = useState(search != undefined);
useEffect(() => { useEffect(() => {
setIsOpen(search != undefined); if (search) {
setIsOpen(search != undefined);
}
}, [search]); }, [search]);
const searchTabs = useMemo(() => { const searchTabs = useMemo(() => {
@ -115,12 +124,6 @@ export default function SearchDetailDialog({
views.splice(index, 1); views.splice(index, 1);
} }
// TODO implement
//if (!config.semantic_search.enabled) {
// const index = views.indexOf("similar-calendar");
// views.splice(index, 1);
// }
return views; return views;
}, [config, search]); }, [config, search]);
@ -130,9 +133,9 @@ export default function SearchDetailDialog({
} }
if (!searchTabs.includes(pageToggle)) { if (!searchTabs.includes(pageToggle)) {
setPage("details"); setSearchPage("details");
} }
}, [pageToggle, searchTabs]); }, [pageToggle, searchTabs, setSearchPage]);
if (!search) { if (!search) {
return; return;
@ -147,14 +150,7 @@ export default function SearchDetailDialog({
const Description = isDesktop ? DialogDescription : MobilePageDescription; const Description = isDesktop ? DialogDescription : MobilePageDescription;
return ( return (
<Overlay <Overlay open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
open={isOpen}
onOpenChange={(open) => {
if (!open) {
setSearch(undefined);
}
}}
>
<Content <Content
className={cn( className={cn(
"scrollbar-container overflow-y-auto", "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 { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
type PlatformAwareDialogProps = { type PlatformAwareDialogProps = {
@ -42,3 +56,60 @@ export default function PlatformAwareDialog({
</Popover> </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" /> <TbExclamationCircle className="mb-3 size-10" />
<div>Search Unavailable</div> <div>Search Unavailable</div>
</div> </div>
{embeddingsReindexing && ( {embeddingsReindexing && allModelsLoaded && (
<> <>
<div className="text-center text-primary-variant"> <div className="text-center text-primary-variant">
Search can be used after tracked object embeddings have Search can be used after tracked object embeddings have
@ -384,6 +384,7 @@ export default function Explore() {
searchFilter={searchFilter} searchFilter={searchFilter}
searchResults={searchResults} searchResults={searchResults}
isLoading={(isLoadingInitialData || isLoadingMore) ?? true} isLoading={(isLoadingInitialData || isLoadingMore) ?? true}
hasMore={!isReachingEnd}
setSearch={setSearch} setSearch={setSearch}
setSimilaritySearch={(search) => { setSimilaritySearch={(search) => {
setSearchFilter({ setSearchFilter({
@ -395,7 +396,7 @@ export default function Explore() {
setSearchFilter={setSearchFilter} setSearchFilter={setSearchFilter}
onUpdateFilter={setSearchFilter} onUpdateFilter={setSearchFilter}
loadMore={loadMore} loadMore={loadMore}
hasMore={!isReachingEnd} refresh={mutate}
/> />
)} )}
</> </>

View File

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

View File

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

View File

@ -1,8 +1,9 @@
import SearchThumbnail from "@/components/card/SearchThumbnail"; import SearchThumbnail from "@/components/card/SearchThumbnail";
import SearchFilterGroup from "@/components/filter/SearchFilterGroup"; import SearchFilterGroup from "@/components/filter/SearchFilterGroup";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import Chip from "@/components/indicators/Chip"; import SearchDetailDialog, {
import SearchDetailDialog from "@/components/overlay/detail/SearchDetailDialog"; SearchTab,
} from "@/components/overlay/detail/SearchDetailDialog";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { import {
Tooltip, Tooltip,
@ -14,7 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { SearchFilter, SearchResult, SearchSource } from "@/types/search"; import { SearchFilter, SearchResult, SearchSource } from "@/types/search";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isDesktop, isMobileOnly } from "react-device-detect"; 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 useSWR from "swr";
import ExploreView from "../explore/ExploreView"; import ExploreView from "../explore/ExploreView";
import useKeyboardListener, { import useKeyboardListener, {
@ -25,7 +26,6 @@ import InputWithTags from "@/components/input/InputWithTags";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import { formatDateToLocaleString } from "@/utils/dateUtil"; import { formatDateToLocaleString } from "@/utils/dateUtil";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { import {
Popover, Popover,
@ -33,6 +33,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { usePersistence } from "@/hooks/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
import SearchThumbnailFooter from "@/components/card/SearchThumbnailFooter";
type SearchViewProps = { type SearchViewProps = {
search: string; search: string;
@ -40,12 +41,13 @@ type SearchViewProps = {
searchFilter?: SearchFilter; searchFilter?: SearchFilter;
searchResults?: SearchResult[]; searchResults?: SearchResult[];
isLoading: boolean; isLoading: boolean;
hasMore: boolean;
setSearch: (search: string) => void; setSearch: (search: string) => void;
setSimilaritySearch: (search: SearchResult) => void; setSimilaritySearch: (search: SearchResult) => void;
setSearchFilter: (filter: SearchFilter) => void; setSearchFilter: (filter: SearchFilter) => void;
onUpdateFilter: (filter: SearchFilter) => void; onUpdateFilter: (filter: SearchFilter) => void;
loadMore: () => void; loadMore: () => void;
hasMore: boolean; refresh: () => void;
}; };
export default function SearchView({ export default function SearchView({
search, search,
@ -53,12 +55,13 @@ export default function SearchView({
searchFilter, searchFilter,
searchResults, searchResults,
isLoading, isLoading,
hasMore,
setSearch, setSearch,
setSimilaritySearch, setSimilaritySearch,
setSearchFilter, setSearchFilter,
onUpdateFilter, onUpdateFilter,
loadMore, loadMore,
hasMore, refresh,
}: SearchViewProps) { }: SearchViewProps) {
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
@ -70,15 +73,17 @@ export default function SearchView({
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4); const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
const effectiveColumnCount = useMemo(() => columnCount ?? 4, [columnCount]); const effectiveColumnCount = useMemo(() => columnCount ?? 4, [columnCount]);
const gridClassName = cn("grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2", { const gridClassName = cn(
"sm:grid-cols-2": effectiveColumnCount <= 2, "grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2",
"sm:grid-cols-3": effectiveColumnCount === 3, isMobileOnly && "grid-cols-2",
"sm:grid-cols-4": effectiveColumnCount === 4, {
"sm:grid-cols-5": effectiveColumnCount === 5, "sm:grid-cols-2": effectiveColumnCount <= 2,
"sm:grid-cols-6": effectiveColumnCount === 6, "sm:grid-cols-3": effectiveColumnCount === 3,
"sm:grid-cols-7": effectiveColumnCount === 7, "sm:grid-cols-4": effectiveColumnCount === 4,
"sm:grid-cols-8": effectiveColumnCount >= 8, "sm:grid-cols-5": effectiveColumnCount === 5,
}); "sm:grid-cols-6": effectiveColumnCount === 6,
},
);
// suggestions values // suggestions values
@ -161,16 +166,25 @@ export default function SearchView({
// detail // detail
const [searchDetail, setSearchDetail] = useState<SearchResult>(); const [searchDetail, setSearchDetail] = useState<SearchResult>();
const [page, setPage] = useState<SearchTab>("details");
// search interaction // search interaction
const [selectedIndex, setSelectedIndex] = useState<number | null>(null); const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const onSelectSearch = useCallback((item: SearchResult, index: number) => { const onSelectSearch = useCallback(
setSearchDetail(item); (item: SearchResult, index: number, page: SearchTab = "details") => {
setSelectedIndex(index); setPage(page);
}, []); setSearchDetail(item);
setSelectedIndex(index);
},
[],
);
useEffect(() => {
setSelectedIndex(0);
}, [searchTerm, searchFilter]);
// update search detail when results change // update search detail when results change
@ -187,21 +201,6 @@ export default function SearchView({
} }
}, [searchResults, searchDetail]); }, [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( const hasExistingSearch = useMemo(
() => searchResults != undefined || searchFilter != undefined, () => searchResults != undefined || searchFilter != undefined,
[searchResults, searchFilter], [searchResults, searchFilter],
@ -310,7 +309,9 @@ export default function SearchView({
<Toaster closeButton={true} /> <Toaster closeButton={true} />
<SearchDetailDialog <SearchDetailDialog
search={searchDetail} search={searchDetail}
page={page}
setSearch={setSearchDetail} setSearch={setSearchDetail}
setSearchPage={setPage}
setSimilarity={ setSimilarity={
searchDetail && (() => setSimilaritySearch(searchDetail)) searchDetail && (() => setSimilaritySearch(searchDetail))
} }
@ -388,47 +389,31 @@ export default function SearchView({
> >
<div <div
className={cn( className={cn(
"aspect-square size-full overflow-hidden rounded-lg", "aspect-square w-full overflow-hidden rounded-t-lg border",
)} )}
> >
<SearchThumbnail <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} searchResult={value}
findSimilar={() => { findSimilar={() => {
if (config?.semantic_search.enabled) { if (config?.semantic_search.enabled) {
setSimilaritySearch(value); 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>
<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> </div>
); );
})} })}
@ -467,7 +452,7 @@ export default function SearchView({
<Slider <Slider
value={[effectiveColumnCount]} value={[effectiveColumnCount]}
onValueChange={([value]) => setColumnCount(value)} onValueChange={([value]) => setColumnCount(value)}
max={8} max={6}
min={2} min={2}
step={1} step={1}
className="flex-grow" 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 PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
const WEEK_STARTS_ON = ["Sunday", "Monday"]; const WEEK_STARTS_ON = ["Sunday", "Monday"];
export default function GeneralSettingsView() { export default function UiSettingsView() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const clearStoredLayouts = useCallback(() => { const clearStoredLayouts = useCallback(() => {