mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-07 02:18:07 +01:00
Miscellaneous fixes (#21141)
* Remove source_type from API * Don't require state classification models to select all classes * Specifically validate provided end_time for manual events * Remove yolov9 specification for warning * Remove warning for coral * clarify zone name tip * clarify replace rules in lpr docs * remove periods * Add explanation for review report * adjust HLS gap controller params defaults to false, should help to recover from hangs and stalling in tracking details videos on chrome * only redirect to login page once on 401 attempt to fix ios pwa safari redirect storm * Use contextual information from other cameras to inform report summary * Formatting and prompt improvements for review summary report * More improvements to prompt * Remove examples * Don't show admin action buttons on export card * fix redirect race condition Coordinate 401 redirect logic between ApiProvider and ProtectedRoute using a shared flag to prevent multiple simultaneous redirects that caused UI flashing. Ensure both auth error paths check and set the redirect flag before navigating to login, eliminating race conditions where both mechanisms could trigger at once --------- Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
12
web/src/api/auth-redirect.ts
Normal file
12
web/src/api/auth-redirect.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Module-level flag to prevent multiple simultaneous redirects
|
||||
// (eg, when multiple SWR queries fail with 401 at once, or when
|
||||
// both ApiProvider and ProtectedRoute try to redirect)
|
||||
let _isRedirectingToLogin = false;
|
||||
|
||||
export function isRedirectingToLogin(): boolean {
|
||||
return _isRedirectingToLogin;
|
||||
}
|
||||
|
||||
export function setRedirectingToLogin(value: boolean): void {
|
||||
_isRedirectingToLogin = value;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { SWRConfig } from "swr";
|
||||
import { WsProvider } from "./ws";
|
||||
import axios from "axios";
|
||||
import { ReactNode } from "react";
|
||||
import { isRedirectingToLogin, setRedirectingToLogin } from "./auth-redirect";
|
||||
|
||||
axios.defaults.baseURL = `${baseUrl}api/`;
|
||||
|
||||
@@ -31,7 +32,8 @@ export function ApiProvider({ children, options }: ApiProviderType) {
|
||||
) {
|
||||
// redirect to the login page if not already there
|
||||
const loginPage = error.response.headers.get("location") ?? "login";
|
||||
if (window.location.href !== loginPage) {
|
||||
if (window.location.href !== loginPage && !isRedirectingToLogin()) {
|
||||
setRedirectingToLogin(true);
|
||||
window.location.href = loginPage;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useContext } from "react";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import {
|
||||
isRedirectingToLogin,
|
||||
setRedirectingToLogin,
|
||||
} from "@/api/auth-redirect";
|
||||
|
||||
export default function ProtectedRoute({
|
||||
requiredRoles,
|
||||
@@ -10,6 +14,20 @@ export default function ProtectedRoute({
|
||||
}) {
|
||||
const { auth } = useContext(AuthContext);
|
||||
|
||||
// Redirect to login page when not authenticated
|
||||
// don't use <Navigate> because we need a full page load to reset state
|
||||
useEffect(() => {
|
||||
if (
|
||||
!auth.isLoading &&
|
||||
auth.isAuthenticated &&
|
||||
!auth.user &&
|
||||
!isRedirectingToLogin()
|
||||
) {
|
||||
setRedirectingToLogin(true);
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}, [auth.isLoading, auth.isAuthenticated, auth.user]);
|
||||
|
||||
if (auth.isLoading) {
|
||||
return (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
@@ -23,7 +41,9 @@ export default function ProtectedRoute({
|
||||
|
||||
// Authenticated mode (8971): require login
|
||||
if (!auth.user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
return (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
);
|
||||
}
|
||||
|
||||
// If role is null (shouldn’t happen if isAuthenticated, but type safety), fallback
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
||||
import BlurredIconButton from "../button/BlurredIconButton";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
|
||||
type ExportProps = {
|
||||
className: string;
|
||||
@@ -40,6 +41,7 @@ export default function ExportCard({
|
||||
onDelete,
|
||||
}: ExportProps) {
|
||||
const { t } = useTranslation(["views/exports"]);
|
||||
const isAdmin = useIsAdmin();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [loading, setLoading] = useState(
|
||||
exportedRecording.thumb_path.length > 0,
|
||||
@@ -195,7 +197,7 @@ export default function ExportCard({
|
||||
</Tooltip>
|
||||
</a>
|
||||
)}
|
||||
{!exportedRecording.in_progress && (
|
||||
{isAdmin && !exportedRecording.in_progress && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BlurredIconButton
|
||||
@@ -212,21 +214,23 @@ export default function ExportCard({
|
||||
<TooltipContent>{t("tooltip.editName")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BlurredIconButton
|
||||
onClick={() =>
|
||||
onDelete({
|
||||
file: exportedRecording.id,
|
||||
exportName: exportedRecording.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
<LuTrash className="size-4 fill-destructive text-destructive hover:text-white" />
|
||||
</BlurredIconButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("tooltip.deleteExport")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{isAdmin && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BlurredIconButton
|
||||
onClick={() =>
|
||||
onDelete({
|
||||
file: exportedRecording.id,
|
||||
exportName: exportedRecording.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
<LuTrash className="size-4 fill-destructive text-destructive hover:text-white" />
|
||||
</BlurredIconButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("tooltip.deleteExport")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -407,30 +407,6 @@ export default function Step3ChooseExamples({
|
||||
return allClasses.every((className) => statesWithExamples.has(className));
|
||||
}, [step1Data.modelType, allClasses, statesWithExamples]);
|
||||
|
||||
// For state models on the last class, require all images to be classified
|
||||
// But allow proceeding even if not all states have examples (with warning)
|
||||
const canProceed = useMemo(() => {
|
||||
if (step1Data.modelType === "state" && isLastClass) {
|
||||
// Check if all 24 images will be classified after current selections are applied
|
||||
const totalImages = unknownImages.slice(0, 24).length;
|
||||
|
||||
// Count images that will be classified (either already classified or currently selected)
|
||||
const allImages = unknownImages.slice(0, 24);
|
||||
const willBeClassified = allImages.filter((img) => {
|
||||
return imageClassifications[img] || selectedImages.has(img);
|
||||
}).length;
|
||||
|
||||
return willBeClassified >= totalImages;
|
||||
}
|
||||
return true;
|
||||
}, [
|
||||
step1Data.modelType,
|
||||
isLastClass,
|
||||
unknownImages,
|
||||
imageClassifications,
|
||||
selectedImages,
|
||||
]);
|
||||
|
||||
const hasUnclassifiedImages = useMemo(() => {
|
||||
if (!unknownImages) return false;
|
||||
const allImages = unknownImages.slice(0, 24);
|
||||
@@ -594,9 +570,7 @@ export default function Step3ChooseExamples({
|
||||
}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
disabled={
|
||||
!hasGenerated || isGenerating || isProcessing || !canProceed
|
||||
}
|
||||
disabled={!hasGenerated || isGenerating || isProcessing}
|
||||
>
|
||||
{isProcessing && <ActivityIndicator className="size-4" />}
|
||||
{t("button.continue", { ns: "common" })}
|
||||
|
||||
@@ -559,6 +559,7 @@ export function TrackingDetails({
|
||||
isDetailMode={true}
|
||||
camera={event.camera}
|
||||
currentTimeOverride={currentTime}
|
||||
enableGapControllerRecovery={true}
|
||||
/>
|
||||
{isVideoLoading && (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import Hls from "hls.js";
|
||||
import Hls, { HlsConfig } from "hls.js";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
||||
import VideoControls from "./VideoControls";
|
||||
@@ -57,6 +57,7 @@ type HlsVideoPlayerProps = {
|
||||
isDetailMode?: boolean;
|
||||
camera?: string;
|
||||
currentTimeOverride?: number;
|
||||
enableGapControllerRecovery?: boolean;
|
||||
};
|
||||
|
||||
export default function HlsVideoPlayer({
|
||||
@@ -81,6 +82,7 @@ export default function HlsVideoPlayer({
|
||||
isDetailMode = false,
|
||||
camera,
|
||||
currentTimeOverride,
|
||||
enableGapControllerRecovery = false,
|
||||
}: HlsVideoPlayerProps) {
|
||||
const { t } = useTranslation("components/player");
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@@ -170,11 +172,23 @@ export default function HlsVideoPlayer({
|
||||
return;
|
||||
}
|
||||
|
||||
hlsRef.current = new Hls({
|
||||
// Base HLS configuration
|
||||
const baseConfig: Partial<HlsConfig> = {
|
||||
maxBufferLength: 10,
|
||||
maxBufferSize: 20 * 1000 * 1000,
|
||||
startPosition: currentSource.startPosition,
|
||||
});
|
||||
};
|
||||
|
||||
const hlsConfig = { ...baseConfig };
|
||||
|
||||
if (enableGapControllerRecovery) {
|
||||
hlsConfig.highBufferWatchdogPeriod = 1; // Check for stalls every 1 second (default: 3)
|
||||
hlsConfig.nudgeOffset = 0.2; // Nudge playhead forward 0.2s when stalled (default: 0.1)
|
||||
hlsConfig.nudgeMaxRetry = 5; // Try up to 5 nudges before giving up (default: 3)
|
||||
hlsConfig.maxBufferHole = 0.5; // Tolerate up to 0.5s gaps between fragments (default: 0.1)
|
||||
}
|
||||
|
||||
hlsRef.current = new Hls(hlsConfig);
|
||||
hlsRef.current.attachMedia(videoRef.current);
|
||||
hlsRef.current.loadSource(currentSource.playlist);
|
||||
videoRef.current.playbackRate = currentPlaybackRate;
|
||||
@@ -187,7 +201,13 @@ export default function HlsVideoPlayer({
|
||||
hlsRef.current.destroy();
|
||||
}
|
||||
};
|
||||
}, [videoRef, hlsRef, useHlsCompat, currentSource]);
|
||||
}, [
|
||||
videoRef,
|
||||
hlsRef,
|
||||
useHlsCompat,
|
||||
currentSource,
|
||||
enableGapControllerRecovery,
|
||||
]);
|
||||
|
||||
// state handling
|
||||
|
||||
|
||||
Reference in New Issue
Block a user