From 8f13932c64a2b0bc2c1e66fe09e491d78e3bbce6 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 8 Apr 2026 09:21:48 -0500 Subject: [PATCH] UI fixes (#22814) * display area as proper percentage in debug view * match replay objects list with debug view * motion search fixes - tweak progress bar to exclude heatmap and inactive segments - show metrics immediately on search start - fix preview frame loading race - fix polygon missing after dialog remount - don't try to drag the image when dragging vertex of polygon * add activity indicator to storage metrics * make sub label query for events API endpoints case insensitive --- frigate/api/chat.py | 2 +- frigate/api/event.py | 30 ++-- .../components/overlay/DebugDrawingLayer.tsx | 9 +- web/src/pages/Replay.tsx | 132 ++++++++++-------- .../motion-search/MotionSearchDialog.tsx | 46 ++++-- .../motion-search/MotionSearchROICanvas.tsx | 49 +++++-- .../views/motion-search/MotionSearchView.tsx | 27 ++-- web/src/views/settings/ObjectSettingsView.tsx | 18 ++- web/src/views/system/StorageMetrics.tsx | 7 +- 9 files changed, 204 insertions(+), 116 deletions(-) diff --git a/frigate/api/chat.py b/frigate/api/chat.py index 3e10714ca..978fa6334 100644 --- a/frigate/api/chat.py +++ b/frigate/api/chat.py @@ -407,7 +407,7 @@ async def _execute_search_objects( query_params = EventsQueryParams( cameras=arguments.get("camera", "all"), labels=arguments.get("label", "all"), - sub_labels=arguments.get("sub_label", "all").lower(), + sub_labels=arguments.get("sub_label", "all"), # case-insensitive on the backend zones=zones, zone=zones, after=after, diff --git a/frigate/api/event.py b/frigate/api/event.py index 001b34025..a7d1cffc8 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -199,13 +199,18 @@ def events( sub_label_clauses.append((Event.sub_label.is_null())) for label in filtered_sub_labels: + lowered = label.lower() sub_label_clauses.append( - (Event.sub_label.cast("text") == label) - ) # include exact matches + (fn.LOWER(Event.sub_label.cast("text")) == lowered) + ) # include exact matches (case-insensitive) - # include this label when part of a list - sub_label_clauses.append((Event.sub_label.cast("text") % f"*{label},*")) - sub_label_clauses.append((Event.sub_label.cast("text") % f"*, {label}*")) + # include this label when part of a list (LIKE is case-insensitive in sqlite for ASCII) + sub_label_clauses.append( + (fn.LOWER(Event.sub_label.cast("text")) % f"*{lowered},*") + ) + sub_label_clauses.append( + (fn.LOWER(Event.sub_label.cast("text")) % f"*, {lowered}*") + ) sub_label_clause = reduce(operator.or_, sub_label_clauses) clauses.append((sub_label_clause)) @@ -609,13 +614,18 @@ def events_search( sub_label_clauses.append((Event.sub_label.is_null())) for label in filtered_sub_labels: + lowered = label.lower() sub_label_clauses.append( - (Event.sub_label.cast("text") == label) - ) # include exact matches + (fn.LOWER(Event.sub_label.cast("text")) == lowered) + ) # include exact matches (case-insensitive) - # include this label when part of a list - sub_label_clauses.append((Event.sub_label.cast("text") % f"*{label},*")) - sub_label_clauses.append((Event.sub_label.cast("text") % f"*, {label}*")) + # include this label when part of a list (LIKE is case-insensitive in sqlite for ASCII) + sub_label_clauses.append( + (fn.LOWER(Event.sub_label.cast("text")) % f"*{lowered},*") + ) + sub_label_clauses.append( + (fn.LOWER(Event.sub_label.cast("text")) % f"*, {lowered}*") + ) event_filters.append((reduce(operator.or_, sub_label_clauses))) diff --git a/web/src/components/overlay/DebugDrawingLayer.tsx b/web/src/components/overlay/DebugDrawingLayer.tsx index 7cc52bc93..845217adc 100644 --- a/web/src/components/overlay/DebugDrawingLayer.tsx +++ b/web/src/components/overlay/DebugDrawingLayer.tsx @@ -8,6 +8,7 @@ import { } from "@/components/ui/popover"; import Konva from "konva"; import { useResizeObserver } from "@/hooks/resize-observer"; +import { useTranslation } from "react-i18next"; type DebugDrawingLayerProps = { containerRef: React.RefObject; @@ -28,6 +29,7 @@ function DebugDrawingLayer({ } | null>(null); const [isDrawing, setIsDrawing] = useState(false); const [showPopover, setShowPopover] = useState(false); + const { t } = useTranslation(["common"]); const stageRef = useRef(null); const [{ width: containerWidth }] = useResizeObserver(containerRef); @@ -153,10 +155,13 @@ function DebugDrawingLayer({
Area:{" "} - px: {calculateArea().toFixed(0)} + {t("information.pixels", { + ns: "common", + area: calculateArea().toFixed(0), + })} - %: {calculateAreaPercentage().toFixed(4)} + {(calculateAreaPercentage() * 100).toFixed(2)}%
diff --git a/web/src/pages/Replay.tsx b/web/src/pages/Replay.tsx index 50927d1dd..2a6ea9ad1 100644 --- a/web/src/pages/Replay.tsx +++ b/web/src/pages/Replay.tsx @@ -41,6 +41,7 @@ import { Toaster } from "@/components/ui/sonner"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { getIconForLabel } from "@/utils/iconUtil"; import { getTranslatedLabel } from "@/utils/i18n"; +import { Card } from "@/components/ui/card"; import { ObjectType } from "@/types/ws"; import WsMessageFeed from "@/components/ws/WsMessageFeed"; import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate"; @@ -633,7 +634,7 @@ type ObjectListProps = { }; function ObjectList({ cameraConfig, objects, config }: ObjectListProps) { - const { t } = useTranslation(["views/settings"]); + const { t } = useTranslation(["views/settings", "common"]); const colormap = useMemo(() => { if (!config) { @@ -660,73 +661,80 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) { } return ( -
+
{objects.map((obj: ObjectType) => { return ( -
+
-
- {getIconForLabel(obj.label, "object", "size-4 text-white")} +
+
+ {getIconForLabel(obj.label, "object", "size-5 text-white")} +
+
+ {getTranslatedLabel(obj.label)} +
-
- {getTranslatedLabel(obj.label)} +
+
+
+

+ {t("debug.objectShapeFilterDrawing.score", { + ns: "views/settings", + })} +

+ {obj.score ? (obj.score * 100).toFixed(1).toString() : "-"}% +
+
+
+
+

+ {t("debug.objectShapeFilterDrawing.ratio", { + ns: "views/settings", + })} +

+ {obj.ratio ? obj.ratio.toFixed(2).toString() : "-"} +
+
+
+
+

+ {t("debug.objectShapeFilterDrawing.area", { + ns: "views/settings", + })} +

+ {obj.area && cameraConfig ? ( +
+
+ {t("information.pixels", { + ns: "common", + area: obj.area, + })} +
+
+ {( + (obj.area / + (cameraConfig.detect.width * + cameraConfig.detect.height)) * + 100 + ).toFixed(2)} + % +
+
+ ) : ( + "-" + )} +
+
-
-
- - {t("debug.objectShapeFilterDrawing.score", { - ns: "views/settings", - })} - : - - - {obj.score ? (obj.score * 100).toFixed(1) : "-"}% - -
- {obj.ratio && ( -
- - {t("debug.objectShapeFilterDrawing.ratio", { - ns: "views/settings", - })} - : - - {obj.ratio.toFixed(2)} -
- )} - {obj.area && cameraConfig && ( -
- - {t("debug.objectShapeFilterDrawing.area", { - ns: "views/settings", - })} - : - - - {obj.area} px ( - {( - (obj.area / - (cameraConfig.detect.width * - cameraConfig.detect.height)) * - 100 - ).toFixed(2)} - %) - -
- )} -
-
+ ); })}
diff --git a/web/src/views/motion-search/MotionSearchDialog.tsx b/web/src/views/motion-search/MotionSearchDialog.tsx index cff49b4b1..ee8652b5e 100644 --- a/web/src/views/motion-search/MotionSearchDialog.tsx +++ b/web/src/views/motion-search/MotionSearchDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { isDesktop, isIOS, isMobile } from "react-device-detect"; import { FaArrowRight, FaCalendarAlt, FaCheckCircle } from "react-icons/fa"; @@ -42,7 +42,6 @@ import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { TimezoneAwareCalendar } from "@/components/overlay/ReviewActivityCalendar"; import { useApiHost } from "@/api"; -import { useResizeObserver } from "@/hooks/resize-observer"; import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils"; import { getUTCOffset } from "@/utils/dateUtil"; import useSWR from "swr"; @@ -113,11 +112,35 @@ export default function MotionSearchDialog({ }: MotionSearchDialogProps) { const { t } = useTranslation(["views/motionSearch", "common"]); const apiHost = useApiHost(); - const containerRef = useRef(null); - const [{ width: containerWidth, height: containerHeight }] = - useResizeObserver(containerRef); + const [containerNode, setContainerNode] = useState( + null, + ); + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const containerWidth = containerSize.width; + const containerHeight = containerSize.height; const [imageLoaded, setImageLoaded] = useState(false); + useEffect(() => { + if (!containerNode) { + return; + } + + const measure = () => { + const rect = containerNode.getBoundingClientRect(); + setContainerSize((prev) => + prev.width === rect.width && prev.height === rect.height + ? prev + : { width: rect.width, height: rect.height }, + ); + }; + + measure(); + + const observer = new ResizeObserver(() => measure()); + observer.observe(containerNode); + return () => observer.disconnect(); + }, [containerNode]); + const cameraConfig = useMemo(() => { if (!selectedCamera) return undefined; return config.cameras[selectedCamera]; @@ -258,16 +281,16 @@ export default function MotionSearchDialog({ }} >
- {selectedCamera && cameraConfig && imageSize.width > 0 ? ( + {selectedCamera && cameraConfig ? (
setImageLoaded(true)} + ref={(node) => { + if (node?.complete && node.naturalWidth > 0) { + setImageLoaded(true); + } + }} /> {!imageLoaded && (
diff --git a/web/src/views/motion-search/MotionSearchROICanvas.tsx b/web/src/views/motion-search/MotionSearchROICanvas.tsx index 43fac7a48..b0d90da81 100644 --- a/web/src/views/motion-search/MotionSearchROICanvas.tsx +++ b/web/src/views/motion-search/MotionSearchROICanvas.tsx @@ -1,10 +1,9 @@ -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Stage, Layer, Line, Circle, Image } from "react-konva"; import Konva from "konva"; import type { KonvaEventObject } from "konva/lib/Node"; import { flattenPoints } from "@/utils/canvasUtil"; import { cn } from "@/lib/utils"; -import { useResizeObserver } from "@/hooks/resize-observer"; type MotionSearchROICanvasProps = { camera: string; @@ -30,18 +29,40 @@ export default function MotionSearchROICanvas({ motionHeatmap, showMotionHeatmap = false, }: MotionSearchROICanvasProps) { - const containerRef = useRef(null); const stageRef = useRef(null); - const [{ width: containerWidth, height: containerHeight }] = - useResizeObserver(containerRef); - - const stageSize = useMemo( - () => ({ - width: containerWidth > 0 ? Math.ceil(containerWidth) : 0, - height: containerHeight > 0 ? Math.ceil(containerHeight) : 0, - }), - [containerHeight, containerWidth], + const [containerNode, setContainerNode] = useState( + null, ); + const [stageSize, setStageSize] = useState({ width: 0, height: 0 }); + + useEffect(() => { + if (!containerNode) { + return; + } + + const apply = (width: number, height: number) => { + setStageSize((prev) => { + const next = { + width: width > 0 ? Math.ceil(width) : 0, + height: height > 0 ? Math.ceil(height) : 0, + }; + if (prev.width === next.width && prev.height === next.height) { + return prev; + } + return next; + }); + }; + + apply(containerNode.clientWidth, containerNode.clientHeight); + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + apply(entry.contentRect.width, entry.contentRect.height); + }); + observer.observe(containerNode); + return () => observer.disconnect(); + }, [containerNode]); const videoRect = useMemo(() => { const stageWidth = stageSize.width; @@ -317,7 +338,7 @@ export default function MotionSearchROICanvas({ return (
e.evt.stopPropagation()} + onTouchStart={(e) => e.evt.stopPropagation()} onDragMove={(e) => handlePointDragMove(e, index)} onMouseOver={(e) => handleMouseOverPoint(e, index)} onMouseOut={(e) => handleMouseOutPoint(e, index)} diff --git a/web/src/views/motion-search/MotionSearchView.tsx b/web/src/views/motion-search/MotionSearchView.tsx index 961c1065c..34c52cc5d 100644 --- a/web/src/views/motion-search/MotionSearchView.tsx +++ b/web/src/views/motion-search/MotionSearchView.tsx @@ -994,15 +994,20 @@ export default function MotionSearchView({ ); const progressMetrics = jobStatus?.metrics ?? searchMetrics; - const progressValue = - progressMetrics && progressMetrics.segments_scanned > 0 - ? Math.min( - 100, - (progressMetrics.segments_processed / - progressMetrics.segments_scanned) * - 100, - ) - : 0; + const progressValue = (() => { + if (!progressMetrics || progressMetrics.segments_scanned <= 0) { + return 0; + } + const skipped = + progressMetrics.heatmap_roi_skip_segments + + progressMetrics.metadata_inactive_segments; + const totalWork = progressMetrics.segments_scanned - skipped; + const doneWork = progressMetrics.segments_processed - skipped; + if (totalWork <= 0) { + return 100; + } + return Math.min(100, Math.max(0, (doneWork / totalWork) * 100)); + })(); const resultsPanel = ( <> @@ -1036,8 +1041,8 @@ export default function MotionSearchView({
)} - {searchMetrics && searchResults.length > 0 && ( -
+ {searchMetrics && (isSearching || searchResults.length > 0) && ( +
{t("metrics.segmentsScanned")} diff --git a/web/src/views/settings/ObjectSettingsView.tsx b/web/src/views/settings/ObjectSettingsView.tsx index 2f4ec5eaf..3179fb85c 100644 --- a/web/src/views/settings/ObjectSettingsView.tsx +++ b/web/src/views/settings/ObjectSettingsView.tsx @@ -370,7 +370,7 @@ type ObjectListProps = { }; function ObjectList({ cameraConfig, objects }: ObjectListProps) { - const { t } = useTranslation(["views/settings"]); + const { t } = useTranslation(["views/settings", "common"]); const { data: config } = useSWR("config"); const colormap = useMemo(() => { @@ -440,17 +440,21 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) { {obj.area ? (
- px: {obj.area.toString()} + {t("information.pixels", { + ns: "common", + area: obj.area, + })}
- %:{" "} {( - obj.area / - (cameraConfig.detect.width * - cameraConfig.detect.height) + (obj.area / + (cameraConfig.detect.width * + cameraConfig.detect.height)) * + 100 ) - .toFixed(4) + .toFixed(2) .toString()} + %
) : ( diff --git a/web/src/views/system/StorageMetrics.tsx b/web/src/views/system/StorageMetrics.tsx index 00855b948..df7287fad 100644 --- a/web/src/views/system/StorageMetrics.tsx +++ b/web/src/views/system/StorageMetrics.tsx @@ -22,6 +22,7 @@ import { Link } from "react-router-dom"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { LuExternalLink } from "react-icons/lu"; import { FaExclamationTriangle } from "react-icons/fa"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; type CameraStorage = { [key: string]: { @@ -128,7 +129,11 @@ export default function StorageMetrics({ }, [stats, config]); if (!cameraStorage || !stats || !totalStorage || !config) { - return; + return ( +
+ +
+ ); } return (