From 72aa68cedcb5138eeec0f96eecee2fce58f27365 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 14 Oct 2024 07:23:10 -0500
Subject: [PATCH 01/12] Fix genai labels (#14330)
* Publish model state and embeddings reindex in dispatcher onConnect
* remove unneeded from explore
* add embeddings reindex progress to statusbar
* don't allow right click or show similar button if semantic search is disabled
* fix status bar
* Convert peewee model to dict before formatting for genai description
* add embeddings reindex progress to statusbar
* fix status bar
* Convert peewee model to dict before formatting for genai description
---
frigate/genai/__init__.py | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py
index dccb74c1d..e2d509383 100644
--- a/frigate/genai/__init__.py
+++ b/frigate/genai/__init__.py
@@ -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):
From 0ee32cf11006e91863b917c4faabee32e901aff8 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 14 Oct 2024 09:23:08 -0500
Subject: [PATCH 02/12] Fix yaml bug and ensure embeddings progress doesn't
show until all models are loaded (#14338)
---
frigate/util/builtin.py | 15 +++++----------
web/src/pages/Explore.tsx | 2 +-
2 files changed, 6 insertions(+), 11 deletions(-)
diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py
index 7c2c5790e..6dab16206 100644
--- a/frigate/util/builtin.py
+++ b/frigate/util/builtin.py
@@ -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)
diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx
index 03a60a8d0..816618fe5 100644
--- a/web/src/pages/Explore.tsx
+++ b/web/src/pages/Explore.tsx
@@ -275,7 +275,7 @@ export default function Explore() {
Search Unavailable
- {embeddingsReindexing && (
+ {embeddingsReindexing && allModelsLoaded && (
<>
Search can be used after tracked object embeddings have
From dd7a07bd0d01a7bef5a689ccc88661a938dff3ea Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 14 Oct 2024 10:27:50 -0500
Subject: [PATCH 03/12] Add ability to rename camera groups (#14339)
* Add ability to rename camera groups
* clean up
* ampersand consistency
---
web/src/components/filter/CameraGroupSelector.tsx | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx
index 731f440e4..d63fcd9bf 100644
--- a/web/src/components/filter/CameraGroupSelector.tsx
+++ b/web/src/components/filter/CameraGroupSelector.tsx
@@ -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({
From 887433fc6ab8cdab17806c2d028c16bf3b05d534 Mon Sep 17 00:00:00 2001
From: Nicolas Mowen
Date: Mon, 14 Oct 2024 15:23:02 -0600
Subject: [PATCH 04/12] Streaming download (#14346)
* Send downloaded mp4 as a streaming response instead of a file
* Add download button to UI
* Formatting
* Fix CSS and text
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
* download video button component
* use download button component in review detail dialog
* better filename
---------
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
---
frigate/api/media.py | 132 ++++++++----------
.../components/button/DownloadVideoButton.tsx | 75 ++++++++++
.../overlay/detail/ReviewDetailDialog.tsx | 22 ++-
3 files changed, 151 insertions(+), 78 deletions(-)
create mode 100644 web/src/components/button/DownloadVideoButton.tsx
diff --git a/frigate/api/media.py b/frigate/api/media.py
index 5915875ab..d89774a6d 100644
--- a/frigate/api/media.py
+++ b/frigate/api/media.py
@@ -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",
diff --git a/web/src/components/button/DownloadVideoButton.tsx b/web/src/components/button/DownloadVideoButton.tsx
new file mode 100644
index 000000000..ffb50098e
--- /dev/null
+++ b/web/src/components/button/DownloadVideoButton.tsx
@@ -0,0 +1,75 @@
+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 handleDownload = async () => {
+ setIsDownloading(true);
+ const formattedDate = formatUnixTimestampToDateTime(startTime, {
+ strftime_fmt: "%D-%T",
+ time_style: "medium",
+ date_style: "medium",
+ });
+ const filename = `${camera}_${formattedDate}.mp4`;
+
+ try {
+ const response = await fetch(source);
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ toast.success(
+ "Your review item video has been downloaded successfully.",
+ {
+ position: "top-center",
+ },
+ );
+ } catch (error) {
+ toast.error(
+ "There was an error downloading the review item video. Please try again.",
+ {
+ position: "top-center",
+ },
+ );
+ } finally {
+ setIsDownloading(false);
+ }
+ };
+
+ return (
+
+
+ {isDownloading ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx
index ae0397470..fb3a95b57 100644
--- a/web/src/components/overlay/detail/ReviewDetailDialog.tsx
+++ b/web/src/components/overlay/detail/ReviewDetailDialog.tsx
@@ -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({
Review item details
- Share this review item
+
+ Share this review item
+
+
+
+
+
+
+
+ Download
+
@@ -180,7 +196,7 @@ export default function ReviewDetailDialog({
-
+
Objects
{events?.map((event) => {
From 3879fde06d5f0b9402528efe141902f8d6434e3f Mon Sep 17 00:00:00 2001
From: Nicolas Mowen
Date: Mon, 14 Oct 2024 16:11:43 -0600
Subject: [PATCH 05/12] Don't allow unlimited unprocessed segments to stay in
cache (#14341)
* Don't allow unlimited unprocessed frames to stay in cache
* Formatting
---
frigate/record/maintainer.py | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py
index f43d1424f..314ff3646 100644
--- a/frigate/record/maintainer.py
+++ b/frigate/record/maintainer.py
@@ -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
From 0abd514064e9ad3e324cc4d9c364e8d5729cc711 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 14 Oct 2024 18:53:25 -0500
Subject: [PATCH 06/12] Use direct download link instead of blob method
(#14347)
---
.../components/button/DownloadVideoButton.tsx | 72 ++++++++-----------
1 file changed, 31 insertions(+), 41 deletions(-)
diff --git a/web/src/components/button/DownloadVideoButton.tsx b/web/src/components/button/DownloadVideoButton.tsx
index ffb50098e..8a8e541fa 100644
--- a/web/src/components/button/DownloadVideoButton.tsx
+++ b/web/src/components/button/DownloadVideoButton.tsx
@@ -18,57 +18,47 @@ export function DownloadVideoButton({
}: DownloadVideoButtonProps) {
const [isDownloading, setIsDownloading] = useState(false);
- const handleDownload = async () => {
- setIsDownloading(true);
- const formattedDate = formatUnixTimestampToDateTime(startTime, {
- strftime_fmt: "%D-%T",
- time_style: "medium",
- date_style: "medium",
- });
- const filename = `${camera}_${formattedDate}.mp4`;
+ const formattedDate = formatUnixTimestampToDateTime(startTime, {
+ strftime_fmt: "%D-%T",
+ time_style: "medium",
+ date_style: "medium",
+ });
+ const filename = `${camera}_${formattedDate}.mp4`;
- try {
- const response = await fetch(source);
- const blob = await response.blob();
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.style.display = "none";
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- window.URL.revokeObjectURL(url);
- toast.success(
- "Your review item video has been downloaded successfully.",
- {
- position: "top-center",
- },
- );
- } catch (error) {
- toast.error(
- "There was an error downloading the review item video. Please try again.",
- {
- position: "top-center",
- },
- );
- } finally {
- setIsDownloading(false);
- }
+ 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 (
);
From 0eccb6a610232394f60044d7dbe54c0c0b7df14f Mon Sep 17 00:00:00 2001
From: Nicolas Mowen
Date: Tue, 15 Oct 2024 07:17:54 -0600
Subject: [PATCH 07/12] Db fixes (#14364)
* Handle case where embeddings overflow token limit
* Set notification tokens
* Fix sort
---
frigate/api/auth.py | 1 +
frigate/embeddings/embeddings.py | 12 +++++++++++-
2 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/frigate/api/auth.py b/frigate/api/auth.py
index 5276eb71e..8976469f5 100644
--- a/frigate/api/auth.py
+++ b/frigate/api/auth.py
@@ -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})
diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py
index cb0626f7b..e4937f955 100644
--- a/frigate/embeddings/embeddings.py
+++ b/frigate/embeddings/embeddings.py
@@ -6,6 +6,7 @@ import logging
import os
import time
+import onnxruntime as ort
from numpy import ndarray
from PIL import Image
from playhouse.shortcuts import model_to_dict
@@ -174,7 +175,16 @@ class Embeddings:
return embedding
def batch_upsert_description(self, event_descriptions: dict[str, str]) -> ndarray:
- embeddings = self.text_embedding(list(event_descriptions.values()))
+ descs = list(event_descriptions.values())
+
+ try:
+ embeddings = self.text_embedding(descs)
+ except ort.RuntimeException:
+ half_size = len(descs) / 2
+ embeddings = []
+ embeddings.extend(self.text_embedding(descs[0:half_size]))
+ embeddings.extend(self.text_embedding(descs[half_size:]))
+
ids = list(event_descriptions.keys())
items = []
From 644069fb239f2774d500b5a4e9b2477c1c8737c0 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Tue, 15 Oct 2024 08:24:47 -0500
Subject: [PATCH 08/12] Explore layout changes (#14348)
* Reset selected index on new searches
* Remove right click for similarity search
* Fix sub label icon
* add card footer
* Add Frigate+ dialog
* Move buttons and menu to thumbnail footer
* Add similarity search
* Show object score
* Implement download buttons
* remove confidence score
* conditionally show submenu items
* Implement delete
* fix icon color
* Add object lifecycle button
* fix score
* delete confirmation
* small tweaks
* consistent icons
---------
Co-authored-by: Nicolas Mowen
---
web/src/components/card/SearchThumbnail.tsx | 57 +++--
.../components/card/SearchThumbnailFooter.tsx | 198 ++++++++++++++++++
web/src/components/input/InputWithTags.tsx | 6 +-
.../overlay/detail/SearchDetailDialog.tsx | 17 +-
web/src/pages/Explore.tsx | 3 +-
web/src/types/frigateConfig.ts | 1 +
web/src/views/search/SearchView.tsx | 97 ++++-----
7 files changed, 283 insertions(+), 96 deletions(-)
create mode 100644 web/src/components/card/SearchThumbnailFooter.tsx
diff --git a/web/src/components/card/SearchThumbnail.tsx b/web/src/components/card/SearchThumbnail.tsx
index fe174e968..4ad7d7c5c 100644
--- a/web/src/components/card/SearchThumbnail.tsx
+++ b/web/src/components/card/SearchThumbnail.tsx
@@ -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("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 (
@@ -80,17 +86,21 @@ export default function SearchThumbnail({
onClick(searchResult)}
>
- {getIconForLabel(searchResult.label, "size-3 text-white")}
+ {getIconForLabel(objectLabel, "size-3 text-white")}
+ {Math.floor(
+ searchResult.score ?? searchResult.data.top_score * 100,
+ )}
+ %
- {[...new Set([searchResult.label])]
+ {[objectLabel]
.filter(
(item) => item !== undefined && !item.includes("-verified"),
)
@@ -103,18 +113,7 @@ export default function SearchThumbnail({
-
-
- {searchResult.end_time ? (
-
- ) : (
-
- )}
- {formattedDate}
-
-
+
);
diff --git a/web/src/components/card/SearchThumbnailFooter.tsx b/web/src/components/card/SearchThumbnailFooter.tsx
new file mode 100644
index 000000000..3e5dbe236
--- /dev/null
+++ b/web/src/components/card/SearchThumbnailFooter.tsx
@@ -0,0 +1,198 @@
+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,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ 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";
+
+type SearchThumbnailProps = {
+ searchResult: SearchResult;
+ findSimilar: () => void;
+ refreshResults: () => void;
+ showObjectLifecycle: () => void;
+};
+
+export default function SearchThumbnailFooter({
+ searchResult,
+ findSimilar,
+ refreshResults,
+ showObjectLifecycle,
+}: SearchThumbnailProps) {
+ const { data: config } = useSWR
("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 (
+ <>
+ setDeleteDialogOpen(!deleteDialogOpen)}
+ >
+
+
+ Confirm Delete
+
+
+ Are you sure you want to delete this tracked object?
+
+
+ Cancel
+
+ Delete
+
+
+
+
+ setShowFrigatePlus(false)}
+ onEventUploaded={() => {}}
+ />
+
+
+ {searchResult.end_time ? (
+
+ ) : (
+
+ )}
+ {formattedDate}
+
+
+ {config?.plus?.enabled &&
+ searchResult.has_snapshot &&
+ searchResult.end_time && (
+
+
+ setShowFrigatePlus(true)}
+ />
+
+ Submit to Frigate+
+
+ )}
+
+ {config?.semantic_search?.enabled && (
+
+
+
+
+ Find similar
+
+ )}
+
+
+
+
+
+
+
+ Tracked Object Actions
+
+
+ {searchResult.has_clip && (
+
+
+
+ Download video
+
+
+ )}
+ {searchResult.has_snapshot && (
+
+
+
+ Download snapshot
+
+
+ )}
+
+
+ View object lifecycle
+
+ setDeleteDialogOpen(true)}>
+
+ Delete
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/components/input/InputWithTags.tsx b/web/src/components/input/InputWithTags.tsx
index 5d0786346..9ca1e4093 100644
--- a/web/src/components/input/InputWithTags.tsx
+++ b/web/src/components/input/InputWithTags.tsx
@@ -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..."
/>
@@ -549,7 +549,7 @@ export default function InputWithTags({
{isSimilaritySearch && (
-
diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx
index 1cee70aaa..23734ea90 100644
--- a/web/src/components/overlay/detail/SearchDetailDialog.tsx
+++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx
@@ -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("config", {
@@ -87,8 +91,11 @@ export default function SearchDetailDialog({
// tabs
- const [page, setPage] = useState("details");
- const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
+ const [pageToggle, setPageToggle] = useOptimisticState(
+ page,
+ setSearchPage,
+ 100,
+ );
// dialog and mobile page
@@ -130,9 +137,9 @@ export default function SearchDetailDialog({
}
if (!searchTabs.includes(pageToggle)) {
- setPage("details");
+ setSearchPage("details");
}
- }, [pageToggle, searchTabs]);
+ }, [pageToggle, searchTabs, setSearchPage]);
if (!search) {
return;
diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx
index 816618fe5..ffbef1060 100644
--- a/web/src/pages/Explore.tsx
+++ b/web/src/pages/Explore.tsx
@@ -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}
/>
)}
>
diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts
index fe889ed9d..2c54b289e 100644
--- a/web/src/types/frigateConfig.ts
+++ b/web/src/types/frigateConfig.ts
@@ -340,6 +340,7 @@ export interface FrigateConfig {
path: string | null;
width: number;
colormap: { [key: string]: [number, number, number] };
+ attributes_map: { [key: string]: [string] };
};
motion: Record | null;
diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx
index e64affa36..bc4f5b54d 100644
--- a/web/src/views/search/SearchView.tsx
+++ b/web/src/views/search/SearchView.tsx
@@ -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(null);
const { data: config } = useSWR("config", {
@@ -76,8 +79,6 @@ export default function SearchView({
"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,
});
// suggestions values
@@ -161,16 +162,25 @@ export default function SearchView({
// detail
const [searchDetail, setSearchDetail] = useState();
+ const [page, setPage] = useState("details");
// search interaction
const [selectedIndex, setSelectedIndex] = useState(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 +197,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 +305,9 @@ export default function SearchView({
setSimilaritySearch(searchDetail))
}
@@ -388,47 +385,31 @@ export default function SearchView({
>
onSelectSearch(value, index)}
+ />
+
+
+
+
{
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")) && (
-
-
-
-
- {value.search_source == "thumbnail" ? (
-
- ) : (
-
- )}
- {zScoreToConfidence(value.search_distance)}%
-
-
-
-
- Matched {value.search_source} at{" "}
- {zScoreToConfidence(value.search_distance)}%
-
-
-
-
- )}
-
);
})}
@@ -467,7 +448,7 @@ export default function SearchView({
setColumnCount(value)}
- max={8}
+ max={6}
min={2}
step={1}
className="flex-grow"
From 25043278ab9a05cf7b21a37ea6ce5c5dea5cf5d9 Mon Sep 17 00:00:00 2001
From: Nicolas Mowen
Date: Tue, 15 Oct 2024 07:40:45 -0600
Subject: [PATCH 09/12] Always run embedding descs one by one (#14365)
---
frigate/embeddings/embeddings.py | 12 ++++--------
1 file changed, 4 insertions(+), 8 deletions(-)
diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py
index e4937f955..f6901614f 100644
--- a/frigate/embeddings/embeddings.py
+++ b/frigate/embeddings/embeddings.py
@@ -175,15 +175,11 @@ class Embeddings:
return embedding
def batch_upsert_description(self, event_descriptions: dict[str, str]) -> ndarray:
- descs = list(event_descriptions.values())
+ # upsert embeddings one by one to avoid token limit
+ embeddings = []
- try:
- embeddings = self.text_embedding(descs)
- except ort.RuntimeException:
- half_size = len(descs) / 2
- embeddings = []
- embeddings.extend(self.text_embedding(descs[0:half_size]))
- embeddings.extend(self.text_embedding(descs[half_size:]))
+ for desc in event_descriptions.values():
+ embeddings.append(self.text_embedding([desc]))
ids = list(event_descriptions.keys())
From b75efcbca2fbacfd119cf4171e422bd16f7f7f6a Mon Sep 17 00:00:00 2001
From: Nicolas Mowen
Date: Tue, 15 Oct 2024 09:37:04 -0600
Subject: [PATCH 10/12] UI tweaks (#14369)
* Adjust text size
* Make cursor consistent
* Fix lint
---
frigate/embeddings/embeddings.py | 1 -
web/src/components/card/SearchThumbnailFooter.tsx | 12 +++++++++---
2 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py
index f6901614f..19b28c6f8 100644
--- a/frigate/embeddings/embeddings.py
+++ b/frigate/embeddings/embeddings.py
@@ -6,7 +6,6 @@ import logging
import os
import time
-import onnxruntime as ort
from numpy import ndarray
from PIL import Image
from playhouse.shortcuts import model_to_dict
diff --git a/web/src/components/card/SearchThumbnailFooter.tsx b/web/src/components/card/SearchThumbnailFooter.tsx
index 3e5dbe236..7947b7642 100644
--- a/web/src/components/card/SearchThumbnailFooter.tsx
+++ b/web/src/components/card/SearchThumbnailFooter.tsx
@@ -112,7 +112,7 @@ export default function SearchThumbnailFooter({
onEventUploaded={() => {}}
/>
-
+
{searchResult.end_time ? (
) : (
@@ -182,11 +182,17 @@ export default function SearchThumbnailFooter({
)}
-
+
View object lifecycle
- setDeleteDialogOpen(true)}>
+ setDeleteDialogOpen(true)}
+ >
Delete
From 3f1ab668999d3c27ee77e251200c0f3da81ec22f Mon Sep 17 00:00:00 2001
From: Nicolas Mowen
Date: Tue, 15 Oct 2024 18:25:59 -0600
Subject: [PATCH 11/12] Embeddings UI updates (#14378)
* Handle Frigate+ submitted case
* Add search settings and rename general to ui settings
* Add platform aware sheet component
* use two columns on mobile view
* Add cameras page to more filters
* clean up search settings view
* Add time range to side filter
* better match with ui settings
* fix icon size
* use two columns on mobile view
* clean up search settings view
* Add zones and saving logic
* Add all filters to side panel
* better match with ui settings
* fix icon size
* Fix mobile fitler page
* Fix embeddings access
* Cleanup
* Fix scroll
* fix double scrollbars and add separators on mobile too
* two columns on mobile
* italics for emphasis
---------
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
---
frigate/embeddings/embeddings.py | 2 +-
.../components/card/SearchThumbnailFooter.tsx | 33 +-
.../components/filter/CamerasFilterButton.tsx | 100 ++-
.../components/filter/SearchFilterGroup.tsx | 754 +-----------------
.../overlay/dialog/PlatformAwareDialog.tsx | 52 ++
.../overlay/dialog/SearchFilterDialog.tsx | 448 +++++++++++
web/src/pages/Settings.tsx | 15 +-
web/src/types/frigateConfig.ts | 5 +-
web/src/views/search/SearchView.tsx | 18 +-
web/src/views/settings/SearchSettingsView.tsx | 288 +++++++
...ralSettingsView.tsx => UiSettingsView.tsx} | 2 +-
11 files changed, 919 insertions(+), 798 deletions(-)
create mode 100644 web/src/components/overlay/dialog/SearchFilterDialog.tsx
create mode 100644 web/src/views/settings/SearchSettingsView.tsx
rename web/src/views/settings/{GeneralSettingsView.tsx => UiSettingsView.tsx} (99%)
diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py
index 19b28c6f8..9ee508823 100644
--- a/frigate/embeddings/embeddings.py
+++ b/frigate/embeddings/embeddings.py
@@ -178,7 +178,7 @@ class Embeddings:
embeddings = []
for desc in event_descriptions.values():
- embeddings.append(self.text_embedding([desc]))
+ embeddings.append(self.text_embedding([desc])[0])
ids = list(event_descriptions.keys())
diff --git a/web/src/components/card/SearchThumbnailFooter.tsx b/web/src/components/card/SearchThumbnailFooter.tsx
index 7947b7642..1a16b3ad0 100644
--- a/web/src/components/card/SearchThumbnailFooter.tsx
+++ b/web/src/components/card/SearchThumbnailFooter.tsx
@@ -10,8 +10,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@@ -33,6 +31,7 @@ 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;
@@ -109,7 +108,9 @@ export default function SearchThumbnailFooter({
showFrigatePlus ? (searchResult as unknown as Event) : undefined
}
onClose={() => setShowFrigatePlus(false)}
- onEventUploaded={() => {}}
+ onEventUploaded={() => {
+ searchResult.plus_id = "submitted";
+ }}
/>
@@ -122,10 +123,12 @@ export default function SearchThumbnailFooter({
)}
{formattedDate}
-
- {config?.plus?.enabled &&
+
+ {!isMobileOnly &&
+ config?.plus?.enabled &&
searchResult.has_snapshot &&
- searchResult.end_time && (
+ searchResult.end_time &&
+ !searchResult.plus_id && (
-
- Tracked Object Actions
-
-
{searchResult.has_clip && (
View object lifecycle
+
+ {isMobileOnly &&
+ config?.plus?.enabled &&
+ searchResult.has_snapshot &&
+ searchResult.end_time &&
+ !searchResult.plus_id && (
+ setShowFrigatePlus(true)}
+ >
+
+ Submit to Frigate+
+
+ )}
setDeleteDialogOpen(true)}
diff --git a/web/src/components/filter/CamerasFilterButton.tsx b/web/src/components/filter/CamerasFilterButton.tsx
index 563af8752..94f1a838e 100644
--- a/web/src/components/filter/CamerasFilterButton.tsx
+++ b/web/src/components/filter/CamerasFilterButton.tsx
@@ -69,6 +69,70 @@ export function CamerasFilterButton({
);
const content = (
+
+ );
+
+ if (isMobile) {
+ return (
+ {
+ if (!open) {
+ setCurrentCameras(selectedCameras);
+ }
+
+ setOpen(open);
+ }}
+ >
+ {trigger}
+
+ {content}
+
+
+ );
+ }
+
+ return (
+ {
+ if (!open) {
+ setCurrentCameras(selectedCameras);
+ }
+ setOpen(open);
+ }}
+ >
+ {trigger}
+ {content}
+
+ );
+}
+
+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({
>
);
-
- if (isMobile) {
- return (
-
{
- if (!open) {
- setCurrentCameras(selectedCameras);
- }
-
- setOpen(open);
- }}
- >
- {trigger}
-
- {content}
-
-
- );
- }
-
- return (
-
{
- if (!open) {
- setCurrentCameras(selectedCameras);
- }
- setOpen(open);
- }}
- >
- {trigger}
- {content}
-
- );
}
diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx
index 8ddb3fee6..5fe301f19 100644
--- a/web/src/components/filter/SearchFilterGroup.tsx
+++ b/web/src/components/filter/SearchFilterGroup.tsx
@@ -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
(() => {
if (filterList?.zones) {
return filterList.zones;
@@ -159,6 +150,15 @@ export default function SearchFilterGroup({
}}
/>
)}
+ {filters.includes("general") && (
+ {
+ onUpdateFilter({ ...filter, labels: newLabels });
+ }}
+ />
+ )}
{filters.includes("date") && (
)}
- {filters.includes("time") && (
-
- onUpdateFilter({ ...filter, time_range })
- }
- />
- )}
- {filters.includes("zone") && allZones.length > 0 && (
-
- onUpdateFilter({ ...filter, zones: newZones })
- }
- />
- )}
- {filters.includes("general") && (
- {
- onUpdateFilter({ ...filter, labels: newLabels });
- }}
- />
- )}
- {filters.includes("sub") && (
-
- onUpdateFilter({ ...filter, sub_labels: newSubLabels })
- }
- />
- )}
- {config?.semantic_search?.enabled &&
- filters.includes("source") &&
- !filter?.search_type?.includes("similarity") && (
-
- onUpdateFilter({ ...filter, search_type: newSearchSource })
- }
- />
- )}
+
);
}
@@ -397,681 +355,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 = (
-
-
-
- {timeRange ? `${formattedAfter} - ${formattedBefore}` : "All Times"}
-
-
- );
- const content = (
-
-
-
-
- {
- if (
- selectedAfterHour == DEFAULT_TIME_RANGE_AFTER &&
- selectedBeforeHour == DEFAULT_TIME_RANGE_BEFORE
- ) {
- updateTimeRange(undefined);
- } else {
- updateTimeRange(`${selectedAfterHour},${selectedBeforeHour}`);
- }
-
- setOpen(false);
- }}
- >
- Apply
-
- {
- setSelectedAfterHour(DEFAULT_TIME_RANGE_AFTER);
- setSelectedBeforeHour(DEFAULT_TIME_RANGE_BEFORE);
- updateTimeRange(undefined);
- }}
- >
- Reset
-
-
-
- );
-
- return (
- {
- 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(
- 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 = (
-
-
-
- {buttonText}
-
-
- );
- const content = (
- setOpen(false)}
- />
- );
-
- return (
- {
- 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 (
- <>
-
- {allZones && setCurrentZones && (
- <>
- {isDesktop &&
}
-
-
- All Zones
-
- {
- if (isChecked) {
- setCurrentZones(undefined);
- }
- }}
- />
-
-
- {allZones.map((item) => (
- {
- 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);
- }
- }
- }}
- />
- ))}
-
- >
- )}
-
- {isDesktop && }
-
- {
- if (updateZoneFilter && selectedZones != currentZones) {
- updateZoneFilter(currentZones);
- }
-
- onClose();
- }}
- >
- Apply
-
- {
- setCurrentZones?.(undefined);
- updateZoneFilter?.(undefined);
- }}
- >
- Reset
-
-
- >
- );
-}
-
-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 = (
-
-
-
- {buttonText}
-
-
- );
- const content = (
- setOpen(false)}
- />
- );
-
- return (
- {
- 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 (
- <>
-
-
-
- All Sub Labels
-
- {
- if (isChecked) {
- setCurrentSubLabels(undefined);
- }
- }}
- />
-
-
- {allSubLabels.map((item) => (
- {
- 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);
- }
- }
- }}
- />
- ))}
-
-
- {isDesktop && }
-
- {
- if (selectedSubLabels != currentSubLabels) {
- updateSubLabelFilter(currentSubLabels);
- }
-
- onClose();
- }}
- >
- Apply
-
- {
- updateSubLabelFilter(undefined);
- }}
- >
- Reset
-
-
- >
- );
-}
-
-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 = (
-
-
-
- {buttonText}
-
-
- );
- const content = (
- setOpen(false)}
- />
- );
-
- return (
-
- );
-}
-
-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 (
- <>
-
-
- {
- 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);
- }
- }
- }}
- />
- {
- 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);
- }
- }
- }}
- />
-
- {isDesktop &&
}
-
- {
- if (selectedSearchSources != currentSearchSources) {
- updateSearchSourceFilter(currentSearchSources);
- }
-
- onClose();
- }}
- >
- Apply
-
- {
- updateSearchSourceFilter(undefined);
- setCurrentSearchSources(["thumbnail", "description"]);
- }}
- >
- Reset
-
-
-
- >
- );
-}
diff --git a/web/src/components/overlay/dialog/PlatformAwareDialog.tsx b/web/src/components/overlay/dialog/PlatformAwareDialog.tsx
index cd84d299c..79ee64f71 100644
--- a/web/src/components/overlay/dialog/PlatformAwareDialog.tsx
+++ b/web/src/components/overlay/dialog/PlatformAwareDialog.tsx
@@ -1,9 +1,16 @@
+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, SheetTrigger } from "@/components/ui/sheet";
import { isMobile } from "react-device-detect";
type PlatformAwareDialogProps = {
@@ -42,3 +49,48 @@ export default function PlatformAwareDialog({
);
}
+
+type PlatformAwareSheetProps = {
+ trigger: JSX.Element;
+ content: JSX.Element;
+ triggerClassName?: string;
+ contentClassName?: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+};
+export function PlatformAwareSheet({
+ trigger,
+ content,
+ triggerClassName = "",
+ contentClassName = "",
+ open,
+ onOpenChange,
+}: PlatformAwareSheetProps) {
+ if (isMobile) {
+ return (
+
+
onOpenChange(true)}>{trigger}
+
+
+ onOpenChange(false)}
+ >
+ More Filters
+
+ {content}
+
+
+
+ );
+ }
+
+ return (
+
+
+ {trigger}
+
+ {content}
+
+ );
+}
diff --git a/web/src/components/overlay/dialog/SearchFilterDialog.tsx b/web/src/components/overlay/dialog/SearchFilterDialog.tsx
new file mode 100644
index 000000000..676e86cff
--- /dev/null
+++ b/web/src/components/overlay/dialog/SearchFilterDialog.tsx
@@ -0,0 +1,448 @@
+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 Heading from "@/components/ui/heading";
+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 trigger = (
+
+
+ More Filters
+
+ );
+ const content = (
+
+
+ setCurrentFilter({ ...currentFilter, time_range: newRange })
+ }
+ />
+
+ setCurrentFilter({ ...currentFilter, zones: newZones })
+ }
+ />
+
+ setCurrentFilter({ ...currentFilter, sub_labels: newSubLabels })
+ }
+ />
+
+ onUpdateFilter({ ...currentFilter, search_type: newSearchSource })
+ }
+ />
+ {isDesktop && }
+
+ {
+ if (currentFilter != filter) {
+ onUpdateFilter(currentFilter);
+ }
+
+ setOpen(false);
+ }}
+ >
+ Apply
+
+ {
+ setCurrentFilter(filter ?? {});
+ }}
+ >
+ Reset
+
+
+
+ );
+
+ return (
+ {
+ 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 (
+
+ );
+}
+
+type ZoneFilterContentProps = {
+ allZones?: string[];
+ zones?: string[];
+ updateZones: (zones: string[] | undefined) => void;
+};
+export function ZoneFilterContent({
+ allZones,
+ zones,
+ updateZones,
+}: ZoneFilterContentProps) {
+ return (
+ <>
+
+
+
Zones
+ {allZones && (
+ <>
+
+
+ All Zones
+
+ {
+ if (isChecked) {
+ updateZones(undefined);
+ }
+ }}
+ />
+
+
+ {allZones.map((item) => (
+ {
+ 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);
+ }
+ }
+ }}
+ />
+ ))}
+
+ >
+ )}
+
+ >
+ );
+}
+
+type SubFilterContentProps = {
+ allSubLabels: string[];
+ subLabels: string[] | undefined;
+ setSubLabels: (labels: string[] | undefined) => void;
+};
+export function SubFilterContent({
+ allSubLabels,
+ subLabels,
+ setSubLabels,
+}: SubFilterContentProps) {
+ return (
+
+
+
Sub Labels
+
+
+ All Sub Labels
+
+ {
+ if (isChecked) {
+ setSubLabels(undefined);
+ }
+ }}
+ />
+
+
+ {allSubLabels.map((item) => (
+ {
+ 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);
+ }
+ }
+ }}
+ />
+ ))}
+
+
+ );
+}
+
+type SearchTypeContentProps = {
+ searchSources: SearchSource[] | undefined;
+ setSearchSources: (sources: SearchSource[] | undefined) => void;
+};
+export function SearchTypeContent({
+ searchSources,
+ setSearchSources,
+}: SearchTypeContentProps) {
+ return (
+ <>
+
+
+
Search Sources
+
+ {
+ 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);
+ }
+ }
+ }}
+ />
+ {
+ 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);
+ }
+ }
+ }}
+ />
+
+
+ >
+ );
+}
diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx
index 6d147b332..4ba29dd08 100644
--- a/web/src/pages/Settings.tsx
+++ b/web/src/pages/Settings.tsx
@@ -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("general");
+ const [page, setPage] = useState("UI settings");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
const tabsRef = useRef(null);
@@ -140,7 +142,7 @@ export default function Settings() {
{Object.values(settingsViews).map((item) => (
- {page == "general" &&
}
+ {page == "UI settings" &&
}
+ {page == "search settings" && (
+
+ )}
{page == "debug" && (
)}
diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts
index 2c54b289e..76d9cfa67 100644
--- a/web/src/types/frigateConfig.ts
+++ b/web/src/types/frigateConfig.ts
@@ -27,6 +27,8 @@ export const ATTRIBUTE_LABELS = [
"ups",
];
+export type SearchModelSize = "small" | "large";
+
export interface CameraConfig {
audio: {
enabled: boolean;
@@ -418,7 +420,8 @@ export interface FrigateConfig {
semantic_search: {
enabled: boolean;
- model_size: string;
+ reindex: boolean;
+ model_size: SearchModelSize;
};
snapshots: {
diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx
index bc4f5b54d..9427cdcff 100644
--- a/web/src/views/search/SearchView.tsx
+++ b/web/src/views/search/SearchView.tsx
@@ -73,13 +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,
- });
+ 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
diff --git a/web/src/views/settings/SearchSettingsView.tsx b/web/src/views/settings/SearchSettingsView.tsx
new file mode 100644
index 000000000..a08816675
--- /dev/null
+++ b/web/src/views/settings/SearchSettingsView.tsx
@@ -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
>;
+};
+
+type SearchSettings = {
+ enabled?: boolean;
+ reindex?: boolean;
+ model_size?: SearchModelSize;
+};
+
+export default function SearchSettingsView({
+ setUnsavedChanges,
+}: SearchSettingsViewProps) {
+ const { data: config, mutate: updateConfig } =
+ useSWR("config");
+ const [changedValue, setChangedValue] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
+
+ const [searchSettings, setSearchSettings] = useState({
+ enabled: undefined,
+ reindex: undefined,
+ model_size: undefined,
+ });
+
+ const [origSearchSettings, setOrigSearchSettings] = useState({
+ 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) => {
+ 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 ;
+ }
+
+ return (
+
+
+
+
+ Search Settings
+
+
+
+ Semantic Search
+
+
+
+
+ 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.
+
+
+
+
+ Read the Documentation
+
+
+
+
+
+
+
+
+
{
+ handleSearchConfigChange({ enabled: isChecked });
+ }}
+ />
+
+ Enabled
+
+
+
+
+
{
+ handleSearchConfigChange({ reindex: isChecked });
+ }}
+ />
+
+ Re-Index On Startup
+
+
+
+ Re-indexing will reprocess all thumbnails and descriptions (if
+ enabled) and apply the embeddings on each startup.{" "}
+ Don't forget to disable the option after restarting!
+
+
+
+
+
Model Size
+
+
+ The size of the model used for semantic search embeddings.
+
+
+
+ Using small employs a quantized version of the
+ model that uses less RAM and runs faster on CPU with a very
+ negligible difference in embedding quality.
+
+
+ Using large employs the full Jina model and will
+ automatically run on the GPU if applicable.
+
+
+
+
+
+ handleSearchConfigChange({
+ model_size: value as SearchModelSize,
+ })
+ }
+ >
+
+ {searchSettings.model_size}
+
+
+
+ {["small", "large"].map((size) => (
+
+ {size}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ Reset
+
+
+ {isLoading ? (
+
+ ) : (
+ "Save"
+ )}
+
+
+
+
+ );
+}
diff --git a/web/src/views/settings/GeneralSettingsView.tsx b/web/src/views/settings/UiSettingsView.tsx
similarity index 99%
rename from web/src/views/settings/GeneralSettingsView.tsx
rename to web/src/views/settings/UiSettingsView.tsx
index 0cb7689f6..c212073c1 100644
--- a/web/src/views/settings/GeneralSettingsView.tsx
+++ b/web/src/views/settings/UiSettingsView.tsx
@@ -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("config");
const clearStoredLayouts = useCallback(() => {
From eda52a3b8273961db60d367c25f552522c514bd6 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Wed, 16 Oct 2024 07:15:25 -0500
Subject: [PATCH 12/12] Search and search filter UI tweaks (#14381)
* fix search type switches
* select/unselect style for more filters button
* fix reset button
* fix labels scrollbar
* set min width and remove modal to allow scrolling with filters open
* hover colors
* better match of font size
* stop sheet from displaying console errors
* fix detail dialog behavior
---
.../components/card/SearchThumbnailFooter.tsx | 8 +--
.../components/filter/SearchFilterGroup.tsx | 8 ++-
.../overlay/detail/SearchDetailDialog.tsx | 19 ++---
.../overlay/dialog/PlatformAwareDialog.tsx | 25 ++++++-
.../overlay/dialog/SearchFilterDialog.tsx | 71 +++++++++++++------
5 files changed, 86 insertions(+), 45 deletions(-)
diff --git a/web/src/components/card/SearchThumbnailFooter.tsx b/web/src/components/card/SearchThumbnailFooter.tsx
index 1a16b3ad0..af7606b37 100644
--- a/web/src/components/card/SearchThumbnailFooter.tsx
+++ b/web/src/components/card/SearchThumbnailFooter.tsx
@@ -113,7 +113,7 @@ export default function SearchThumbnailFooter({
}}
/>
-
+
{searchResult.end_time ? (
) : (
@@ -132,7 +132,7 @@ export default function SearchThumbnailFooter({
setShowFrigatePlus(true)}
/>
@@ -144,7 +144,7 @@ export default function SearchThumbnailFooter({
@@ -154,7 +154,7 @@ export default function SearchThumbnailFooter({
-
+
{searchResult.has_clip && (
diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx
index 5fe301f19..ef816bb9f 100644
--- a/web/src/components/filter/SearchFilterGroup.tsx
+++ b/web/src/components/filter/SearchFilterGroup.tsx
@@ -253,7 +253,11 @@ function GeneralFilterButton({
{
if (!open) {
@@ -284,7 +288,7 @@ export function GeneralFilterContent({
}: GeneralFilterContentProps) {
return (
<>
-
+
{
- setIsOpen(search != undefined);
+ if (search) {
+ setIsOpen(search != undefined);
+ }
}, [search]);
const searchTabs = useMemo(() => {
@@ -122,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]);
@@ -154,14 +150,7 @@ export default function SearchDetailDialog({
const Description = isDesktop ? DialogDescription : MobilePageDescription;
return (
- {
- if (!open) {
- setSearch(undefined);
- }
- }}
- >
+ setIsOpen(!isOpen)}>
void;
};
export function PlatformAwareSheet({
trigger,
+ title,
content,
triggerClassName = "",
+ titleClassName = "",
contentClassName = "",
open,
onOpenChange,
@@ -86,11 +97,19 @@ export function PlatformAwareSheet({
}
return (
-
+
{trigger}
- {content}
+
+
+
+ {title ?? ""}
+
+ Information
+
+ {content}
+
);
}
diff --git a/web/src/components/overlay/dialog/SearchFilterDialog.tsx b/web/src/components/overlay/dialog/SearchFilterDialog.tsx
index 676e86cff..9409707b6 100644
--- a/web/src/components/overlay/dialog/SearchFilterDialog.tsx
+++ b/web/src/components/overlay/dialog/SearchFilterDialog.tsx
@@ -18,7 +18,6 @@ import {
} from "@/components/ui/popover";
import { isDesktop, isMobileOnly } from "react-device-detect";
import { useFormattedHour } from "@/hooks/use-date-utils";
-import Heading from "@/components/ui/heading";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
@@ -51,9 +50,27 @@ export default function SearchFilterDialog({
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 = (
-
-
+
+
More Filters
);
@@ -80,14 +97,20 @@ export default function SearchFilterDialog({
setCurrentFilter({ ...currentFilter, sub_labels: newSubLabels })
}
/>
-
- onUpdateFilter({ ...currentFilter, search_type: newSearchSource })
- }
- />
+ {config?.semantic_search?.enabled &&
+ !currentFilter?.search_type?.includes("similarity") && (
+
+ setCurrentFilter({
+ ...currentFilter,
+ search_type: newSearchSource,
+ })
+ }
+ />
+ )}
{isDesktop && }
{
- setCurrentFilter(filter ?? {});
+ setCurrentFilter((prevFilter) => ({
+ ...prevFilter,
+ time_range: undefined,
+ zones: undefined,
+ sub_labels: undefined,
+ search_type: ["thumbnail", "description"],
+ }));
}}
>
Reset
@@ -118,7 +147,7 @@ export default function SearchFilterDialog({
trigger={trigger}
content={content}
contentClassName={cn(
- "w-auto lg:w-[300px] scrollbar-container h-full overflow-auto px-4",
+ "w-auto lg:min-w-[275px] scrollbar-container h-full overflow-auto px-4",
isMobileOnly && "pb-20",
)}
open={open}
@@ -184,8 +213,8 @@ function TimeRangeFilterContent({
return (
-
Time Range
-
+
Time Range
+
{
@@ -280,7 +309,7 @@ export function ZoneFilterContent({
<>
-
Zones
+
Zones
{allZones && (
<>
@@ -301,7 +330,7 @@ export function ZoneFilterContent({
}}
/>
-
+
{allZones.map((item) => (
- Sub Labels
+ Sub Labels
All Sub Labels
@@ -362,7 +391,7 @@ export function SubFilterContent({
}}
/>
-
+
{allSubLabels.map((item) => (