mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-03 22:04:53 +03:00
Merge remote-tracking branch 'upstream/dev' into feature/share-review-timestamp
This commit is contained in:
commit
da877214d5
@ -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}")
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>(() => {
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) =>
|
||||||
|
|||||||
@ -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")}
|
||||||
|
|||||||
@ -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)" },
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user