diff --git a/frigate/test/test_proxy_auth.py b/frigate/test/test_proxy_auth.py index 2ffad957c..e4d2c9ce9 100644 --- a/frigate/test/test_proxy_auth.py +++ b/frigate/test/test_proxy_auth.py @@ -2,6 +2,7 @@ import unittest from frigate.api.auth import resolve_role from frigate.config import HeaderMappingConfig, ProxyConfig +from frigate.config.env import FRIGATE_ENV_VARS class TestProxyRoleResolution(unittest.TestCase): @@ -91,3 +92,39 @@ class TestProxyRoleResolution(unittest.TestCase): headers = {"x-remote-role": "group_unknown"} role = resolve_role(headers, self.proxy_config, self.config_roles) self.assertEqual(role, self.proxy_config.default_role) + + +class TestProxyAuthSecretEnvString(unittest.TestCase): + def setUp(self): + self._original_env_vars = dict(FRIGATE_ENV_VARS) + + def tearDown(self): + FRIGATE_ENV_VARS.clear() + FRIGATE_ENV_VARS.update(self._original_env_vars) + + def test_auth_secret_env_substitution(self): + """auth_secret resolves FRIGATE_ env vars via EnvString.""" + FRIGATE_ENV_VARS["FRIGATE_PROXY_SECRET"] = "my_secret_value" + config = ProxyConfig(auth_secret="{FRIGATE_PROXY_SECRET}") + self.assertEqual(config.auth_secret, "my_secret_value") + + def test_auth_secret_env_embedded_in_string(self): + """auth_secret resolves env vars embedded in a larger string.""" + FRIGATE_ENV_VARS["FRIGATE_SECRET_PART"] = "abc123" + config = ProxyConfig(auth_secret="prefix-{FRIGATE_SECRET_PART}-suffix") + self.assertEqual(config.auth_secret, "prefix-abc123-suffix") + + def test_auth_secret_plain_string(self): + """auth_secret accepts a plain string without substitution.""" + config = ProxyConfig(auth_secret="literal_secret") + self.assertEqual(config.auth_secret, "literal_secret") + + def test_auth_secret_none(self): + """auth_secret defaults to None.""" + config = ProxyConfig() + self.assertIsNone(config.auth_secret) + + def test_auth_secret_unknown_var_raises(self): + """auth_secret raises KeyError for unknown env var references.""" + with self.assertRaises(Exception): + ProxyConfig(auth_secret="{FRIGATE_NONEXISTENT_VAR}") diff --git a/frigate/util/object.py b/frigate/util/object.py index 021150132..7f38438f4 100644 --- a/frigate/util/object.py +++ b/frigate/util/object.py @@ -271,18 +271,17 @@ def get_min_region_size(model_config: ModelConfig) -> int: """Get the min region size.""" largest_dimension = max(model_config.height, model_config.width) - if largest_dimension > 320: - # We originally tested allowing any model to have a region down to half of the model size - # but this led to many false positives. In this case we specifically target larger models - # which can benefit from a smaller region in some cases to detect smaller objects. - half = int(largest_dimension / 2) + # return largest dimension for smaller models, but make sure the dimension is normalized + if largest_dimension < 320: + if largest_dimension % 4 == 0: + return largest_dimension - if half % 4 == 0: - return half + return int((largest_dimension + 3) / 4) * 4 - return int((half + 3) / 4) * 4 - - return largest_dimension + # Any model that is 320 or larger should have a minimum region size of 320 + # this allows larger models to use smaller regions to detect smaller objects + # in the case that the motion area is smaller so that it can be upscaled. + return 320 def create_tensor_input(frame, model_config: ModelConfig, region): diff --git a/web/index.html b/web/index.html index 0805deca3..be6b302f5 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ - + Frigate { + detailOpenRef.current = detailOpen; + }, [detailOpen]); + + useEffect(() => { + return () => { + // Only pop the state if we are still sitting on the overlayOpen history entry. + // This prevents the unmount from undoing cross-page routing if the unmount + // was caused by navigating away to a different view. + if (detailOpenRef.current && window.history.state?.overlayOpen) { + window.history.back(); + } + }; + }, []); + // data const bestItem = useMemo(() => { diff --git a/web/src/components/ui/collapsible.tsx b/web/src/components/ui/collapsible.tsx index a23e7a281..2b5b07882 100644 --- a/web/src/components/ui/collapsible.tsx +++ b/web/src/components/ui/collapsible.tsx @@ -1,9 +1,28 @@ -import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; +import * as React from "react"; -const Collapsible = CollapsiblePrimitive.Root +import { cn } from "@/lib/utils"; -const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger +const Collapsible = CollapsiblePrimitive.Root; -const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; -export { Collapsible, CollapsibleTrigger, CollapsibleContent } +const CollapsibleContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + +)); +CollapsibleContent.displayName = + CollapsiblePrimitive.CollapsibleContent.displayName; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/web/src/hooks/use-overlay-state.tsx b/web/src/hooks/use-overlay-state.tsx index b2ef9d2f1..8efe6b0f2 100644 --- a/web/src/hooks/use-overlay-state.tsx +++ b/web/src/hooks/use-overlay-state.tsx @@ -1,4 +1,11 @@ -import { useCallback, useContext, useEffect, useMemo, useRef } from "react"; +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { usePersistence } from "./use-persistence"; import { useUserPersistence } from "./use-user-persistence"; @@ -200,36 +207,51 @@ export function useSearchEffect( const location = useLocation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); + const [pendingRemoval, setPendingRemoval] = useState(false); + const processedRef = useRef(null); - const param = useMemo(() => { - const param = searchParams.get(key); - - if (!param) { - return undefined; - } - - return [key, decodeURIComponent(param)]; - }, [searchParams, key]); + const currentParam = searchParams.get(key); + // Process the param via callback (once per unique param value) useEffect(() => { - if (!param) { + if (currentParam == null || currentParam === processedRef.current) { return; } - const remove = callback(param[1]); + const decoded = decodeURIComponent(currentParam); + const shouldRemove = callback(decoded); - if (remove) { - navigate(location.pathname + location.hash, { - state: location.state, - replace: true, - }); + if (shouldRemove) { + processedRef.current = currentParam; + setPendingRemoval(true); } + }, [currentParam, callback, key]); + + // Remove the search param in a separate render cycle so that any state + // changes from the callback (e.g., overlay state navigations) are already + // reflected in location.state before we navigate to strip the param. + useEffect(() => { + if (!pendingRemoval) { + return; + } + + setPendingRemoval(false); + navigate(location.pathname + location.hash, { + state: location.state, + replace: true, + }); }, [ - param, - location.state, + pendingRemoval, + navigate, location.pathname, location.hash, - callback, - navigate, + location.state, ]); + + // Reset tracking when param is removed from the URL + useEffect(() => { + if (currentParam == null) { + processedRef.current = null; + } + }, [currentParam]); } diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index e3f6e4fae..c4a1f1c6d 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -409,8 +409,11 @@ export default function Events() { // review status - const markAllItemsAsReviewed = useCallback( - async (currentItems: ReviewSegment[]) => { + const markItemsAsReviewed = useCallback( + async ( + currentItems: ReviewSegment[], + itemsToMarkReviewed: ReviewSegment[] | undefined = undefined, + ) => { if (currentItems.length == 0) { return; } @@ -435,13 +438,14 @@ export default function Events() { { revalidate: false, populateCache: true }, ); - const itemsToMarkReviewed = currentItems - ?.filter((seg) => seg.end_time) - ?.map((seg) => seg.id); + const reviewList = + itemsToMarkReviewed?.map((seg) => seg.id) || + currentItems?.filter((seg) => seg.end_time)?.map((seg) => seg.id) || + []; - if (itemsToMarkReviewed.length > 0) { + if (reviewList.length > 0) { await axios.post(`reviews/viewed`, { - ids: itemsToMarkReviewed, + ids: reviewList, reviewed: true, }); reloadData(); @@ -611,7 +615,7 @@ export default function Events() { setShowReviewed={setShowReviewed} setSeverity={setSeverity} markItemAsReviewed={markItemAsReviewed} - markAllItemsAsReviewed={markAllItemsAsReviewed} + markItemsAsReviewed={markItemsAsReviewed} onOpenRecording={setRecording} motionPreviewsCamera={motionPreviewsCamera ?? null} setMotionPreviewsCamera={(camera) => diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 442fb91d4..919bf708a 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -110,7 +110,10 @@ type EventViewProps = { setShowReviewed: (show: boolean) => void; setSeverity: (severity: ReviewSeverity) => void; markItemAsReviewed: (review: ReviewSegment) => void; - markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; + markItemsAsReviewed: ( + currentItems: ReviewSegment[], + itemsToMarkReviewed?: ReviewSegment[] | undefined, + ) => void; onOpenRecording: (recordingInfo: RecordingStartingPoint) => void; motionPreviewsCamera: string | null; setMotionPreviewsCamera: (camera: string | null) => void; @@ -132,7 +135,7 @@ export default function EventView({ setShowReviewed, setSeverity, markItemAsReviewed, - markAllItemsAsReviewed, + markItemsAsReviewed, onOpenRecording, motionPreviewsCamera, setMotionPreviewsCamera, @@ -498,7 +501,7 @@ export default function EventView({ loading={severity != severityToggle} emptyCardData={emptyCardData} markItemAsReviewed={markItemAsReviewed} - markAllItemsAsReviewed={markAllItemsAsReviewed} + markItemsAsReviewed={markItemsAsReviewed} onSelectReview={onSelectReview} onSelectAllReviews={onSelectAllReviews} setSelectedReviews={setSelectedReviews} @@ -549,7 +552,10 @@ type DetectionReviewProps = { loading: boolean; emptyCardData: EmptyCardData; markItemAsReviewed: (review: ReviewSegment) => void; - markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; + markItemsAsReviewed: ( + currentItems: ReviewSegment[], + itemsToMarkReviewed?: ReviewSegment[] | undefined, + ) => void; onSelectReview: ( review: ReviewSegment, ctrl: boolean, @@ -573,7 +579,7 @@ function DetectionReview({ loading, emptyCardData, markItemAsReviewed, - markAllItemsAsReviewed, + markItemsAsReviewed, onSelectReview, onSelectAllReviews, setSelectedReviews, @@ -788,12 +794,7 @@ function DetectionReview({ break; case "r": if (selectedReviews.length > 0 && !modifiers.repeat) { - currentItems?.forEach((item) => { - if (selectedReviews.some((r) => r.id === item.id)) { - item.has_been_reviewed = true; - markItemAsReviewed(item); - } - }); + markItemsAsReviewed(currentItems || [], selectedReviews); setSelectedReviews([]); return true; } @@ -903,7 +904,7 @@ function DetectionReview({ variant="select" onClick={() => { setSelectedReviews([]); - markAllItemsAsReviewed(currentItems ?? []); + markItemsAsReviewed(currentItems ?? []); }} > {t("markTheseItemsAsReviewed")} diff --git a/web/tailwind.config.cjs b/web/tailwind.config.cjs index 81eee070c..cfd738d6e 100644 --- a/web/tailwind.config.cjs +++ b/web/tailwind.config.cjs @@ -40,6 +40,8 @@ module.exports = { animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", + "collapsible-down": "collapsible-down 0.2s ease-out", + "collapsible-up": "collapsible-up 0.2s ease-out", move: "move 3s ease-in-out infinite", scale1: "scale1 3s ease-in-out infinite", scale2: "scale2 3s ease-in-out infinite", @@ -148,6 +150,14 @@ module.exports = { from: { height: "var(--radix-accordion-content-height)" }, to: { height: 0 }, }, + "collapsible-down": { + from: { height: "0px" }, + to: { height: "var(--radix-collapsible-content-height)" }, + }, + "collapsible-up": { + from: { height: "var(--radix-collapsible-content-height)" }, + to: { height: "0px" }, + }, move: { "50%": { left: "calc(100% - 7px)" }, },