Merge remote-tracking branch 'upstream/dev' into feature/share-review-timestamp

This commit is contained in:
0x464e 2026-03-25 00:49:28 +02:00
commit da877214d5
No known key found for this signature in database
GPG Key ID: E6D221DF6CBFBFFA
9 changed files with 169 additions and 58 deletions

View File

@ -2,6 +2,7 @@ import unittest
from frigate.api.auth import resolve_role from frigate.api.auth import resolve_role
from frigate.config import HeaderMappingConfig, ProxyConfig from frigate.config import HeaderMappingConfig, ProxyConfig
from frigate.config.env import FRIGATE_ENV_VARS
class TestProxyRoleResolution(unittest.TestCase): class TestProxyRoleResolution(unittest.TestCase):
@ -91,3 +92,39 @@ class TestProxyRoleResolution(unittest.TestCase):
headers = {"x-remote-role": "group_unknown"} headers = {"x-remote-role": "group_unknown"}
role = resolve_role(headers, self.proxy_config, self.config_roles) role = resolve_role(headers, self.proxy_config, self.config_roles)
self.assertEqual(role, self.proxy_config.default_role) 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}")

View File

@ -271,18 +271,17 @@ def get_min_region_size(model_config: ModelConfig) -> int:
"""Get the min region size.""" """Get the min region size."""
largest_dimension = max(model_config.height, model_config.width) largest_dimension = max(model_config.height, model_config.width)
if largest_dimension > 320: # return largest dimension for smaller models, but make sure the dimension is normalized
# We originally tested allowing any model to have a region down to half of the model size if largest_dimension < 320:
# but this led to many false positives. In this case we specifically target larger models if largest_dimension % 4 == 0:
# which can benefit from a smaller region in some cases to detect smaller objects. return largest_dimension
half = int(largest_dimension / 2)
if half % 4 == 0: return int((largest_dimension + 3) / 4) * 4
return half
return int((half + 3) / 4) * 4 # 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
return largest_dimension # 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): def create_tensor_input(frame, model_config: ModelConfig, region):

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/images/branding/favicon.ico" /> <link rel="icon" href="/images/branding/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Frigate</title> <title>Frigate</title>
<link <link
rel="apple-touch-icon" rel="apple-touch-icon"

View File

@ -6,7 +6,7 @@ import {
ClassificationThreshold, ClassificationThreshold,
ClassifiedEvent, ClassifiedEvent,
} from "@/types/classification"; } from "@/types/classification";
import { forwardRef, useMemo, useRef, useState } from "react"; import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
import { isDesktop, isIOS, isMobile, isMobileOnly } from "react-device-detect"; import { isDesktop, isIOS, isMobile, isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import TimeAgo from "../dynamic/TimeAgo"; import TimeAgo from "../dynamic/TimeAgo";
@ -216,6 +216,25 @@ export function GroupedClassificationCard({
const { t } = useTranslation(["views/explore", i18nLibrary]); const { t } = useTranslation(["views/explore", i18nLibrary]);
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
// If the component unmounts while the detail overlay is open, we need to
// pop the history state that was pushed by useHistoryBack, otherwise it
// leaves a stale entry that breaks back navigation.
const detailOpenRef = useRef(detailOpen);
useEffect(() => {
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 // data
const bestItem = useMemo<ClassificationItemData | undefined>(() => { const bestItem = useMemo<ClassificationItemData | undefined>(() => {

View File

@ -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<typeof CollapsiblePrimitive.CollapsibleContent>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
>(({ className, children, ...props }, ref) => (
<CollapsiblePrimitive.CollapsibleContent
ref={ref}
className={cn(
"overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
className,
)}
{...props}
>
{children}
</CollapsiblePrimitive.CollapsibleContent>
));
CollapsibleContent.displayName =
CollapsiblePrimitive.CollapsibleContent.displayName;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@ -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 { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { usePersistence } from "./use-persistence"; import { usePersistence } from "./use-persistence";
import { useUserPersistence } from "./use-user-persistence"; import { useUserPersistence } from "./use-user-persistence";
@ -200,36 +207,51 @@ export function useSearchEffect(
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [pendingRemoval, setPendingRemoval] = useState(false);
const processedRef = useRef<string | null>(null);
const param = useMemo(() => { const currentParam = searchParams.get(key);
const param = searchParams.get(key);
if (!param) {
return undefined;
}
return [key, decodeURIComponent(param)];
}, [searchParams, key]);
// Process the param via callback (once per unique param value)
useEffect(() => { useEffect(() => {
if (!param) { if (currentParam == null || currentParam === processedRef.current) {
return; return;
} }
const remove = callback(param[1]); const decoded = decodeURIComponent(currentParam);
const shouldRemove = callback(decoded);
if (remove) { if (shouldRemove) {
navigate(location.pathname + location.hash, { processedRef.current = currentParam;
state: location.state, setPendingRemoval(true);
replace: 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, pendingRemoval,
location.state, navigate,
location.pathname, location.pathname,
location.hash, location.hash,
callback, location.state,
navigate,
]); ]);
// Reset tracking when param is removed from the URL
useEffect(() => {
if (currentParam == null) {
processedRef.current = null;
}
}, [currentParam]);
} }

View File

@ -465,8 +465,11 @@ export default function Events() {
// review status // review status
const markAllItemsAsReviewed = useCallback( const markItemsAsReviewed = useCallback(
async (currentItems: ReviewSegment[]) => { async (
currentItems: ReviewSegment[],
itemsToMarkReviewed: ReviewSegment[] | undefined = undefined,
) => {
if (currentItems.length == 0) { if (currentItems.length == 0) {
return; return;
} }
@ -491,13 +494,14 @@ export default function Events() {
{ revalidate: false, populateCache: true }, { revalidate: false, populateCache: true },
); );
const itemsToMarkReviewed = currentItems const reviewList =
?.filter((seg) => seg.end_time) itemsToMarkReviewed?.map((seg) => seg.id) ||
?.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`, { await axios.post(`reviews/viewed`, {
ids: itemsToMarkReviewed, ids: reviewList,
reviewed: true, reviewed: true,
}); });
reloadData(); reloadData();
@ -667,7 +671,7 @@ export default function Events() {
setShowReviewed={setShowReviewed} setShowReviewed={setShowReviewed}
setSeverity={setSeverity} setSeverity={setSeverity}
markItemAsReviewed={markItemAsReviewed} markItemAsReviewed={markItemAsReviewed}
markAllItemsAsReviewed={markAllItemsAsReviewed} markItemsAsReviewed={markItemsAsReviewed}
onOpenRecording={setRecording} onOpenRecording={setRecording}
motionPreviewsCamera={motionPreviewsCamera ?? null} motionPreviewsCamera={motionPreviewsCamera ?? null}
setMotionPreviewsCamera={(camera) => setMotionPreviewsCamera={(camera) =>

View File

@ -110,7 +110,10 @@ type EventViewProps = {
setShowReviewed: (show: boolean) => void; setShowReviewed: (show: boolean) => void;
setSeverity: (severity: ReviewSeverity) => void; setSeverity: (severity: ReviewSeverity) => void;
markItemAsReviewed: (review: ReviewSegment) => void; markItemAsReviewed: (review: ReviewSegment) => void;
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; markItemsAsReviewed: (
currentItems: ReviewSegment[],
itemsToMarkReviewed?: ReviewSegment[] | undefined,
) => void;
onOpenRecording: (recordingInfo: RecordingStartingPoint) => void; onOpenRecording: (recordingInfo: RecordingStartingPoint) => void;
motionPreviewsCamera: string | null; motionPreviewsCamera: string | null;
setMotionPreviewsCamera: (camera: string | null) => void; setMotionPreviewsCamera: (camera: string | null) => void;
@ -132,7 +135,7 @@ export default function EventView({
setShowReviewed, setShowReviewed,
setSeverity, setSeverity,
markItemAsReviewed, markItemAsReviewed,
markAllItemsAsReviewed, markItemsAsReviewed,
onOpenRecording, onOpenRecording,
motionPreviewsCamera, motionPreviewsCamera,
setMotionPreviewsCamera, setMotionPreviewsCamera,
@ -498,7 +501,7 @@ export default function EventView({
loading={severity != severityToggle} loading={severity != severityToggle}
emptyCardData={emptyCardData} emptyCardData={emptyCardData}
markItemAsReviewed={markItemAsReviewed} markItemAsReviewed={markItemAsReviewed}
markAllItemsAsReviewed={markAllItemsAsReviewed} markItemsAsReviewed={markItemsAsReviewed}
onSelectReview={onSelectReview} onSelectReview={onSelectReview}
onSelectAllReviews={onSelectAllReviews} onSelectAllReviews={onSelectAllReviews}
setSelectedReviews={setSelectedReviews} setSelectedReviews={setSelectedReviews}
@ -549,7 +552,10 @@ type DetectionReviewProps = {
loading: boolean; loading: boolean;
emptyCardData: EmptyCardData; emptyCardData: EmptyCardData;
markItemAsReviewed: (review: ReviewSegment) => void; markItemAsReviewed: (review: ReviewSegment) => void;
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; markItemsAsReviewed: (
currentItems: ReviewSegment[],
itemsToMarkReviewed?: ReviewSegment[] | undefined,
) => void;
onSelectReview: ( onSelectReview: (
review: ReviewSegment, review: ReviewSegment,
ctrl: boolean, ctrl: boolean,
@ -573,7 +579,7 @@ function DetectionReview({
loading, loading,
emptyCardData, emptyCardData,
markItemAsReviewed, markItemAsReviewed,
markAllItemsAsReviewed, markItemsAsReviewed,
onSelectReview, onSelectReview,
onSelectAllReviews, onSelectAllReviews,
setSelectedReviews, setSelectedReviews,
@ -788,12 +794,7 @@ function DetectionReview({
break; break;
case "r": case "r":
if (selectedReviews.length > 0 && !modifiers.repeat) { if (selectedReviews.length > 0 && !modifiers.repeat) {
currentItems?.forEach((item) => { markItemsAsReviewed(currentItems || [], selectedReviews);
if (selectedReviews.some((r) => r.id === item.id)) {
item.has_been_reviewed = true;
markItemAsReviewed(item);
}
});
setSelectedReviews([]); setSelectedReviews([]);
return true; return true;
} }
@ -903,7 +904,7 @@ function DetectionReview({
variant="select" variant="select"
onClick={() => { onClick={() => {
setSelectedReviews([]); setSelectedReviews([]);
markAllItemsAsReviewed(currentItems ?? []); markItemsAsReviewed(currentItems ?? []);
}} }}
> >
{t("markTheseItemsAsReviewed")} {t("markTheseItemsAsReviewed")}

View File

@ -40,6 +40,8 @@ module.exports = {
animation: { animation: {
"accordion-down": "accordion-down 0.2s ease-out", "accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 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", move: "move 3s ease-in-out infinite",
scale1: "scale1 3s ease-in-out infinite", scale1: "scale1 3s ease-in-out infinite",
scale2: "scale2 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)" }, from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 }, 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: { move: {
"50%": { left: "calc(100% - 7px)" }, "50%": { left: "calc(100% - 7px)" },
}, },