From 2c217cc124db026ab5671531cb11251f535d18e5 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 18 May 2026 21:53:27 -0500 Subject: [PATCH] broadcast debug replay state over ws and buffer pre-OPEN sends - push debug replay session state over the job_state ws topic so the status bar reacts instantly to start/stop without polling - fix child-effect-before-parent-effect race in WsProvider that silently dropped initial snapshot requests on cold load --- frigate/debug_replay.py | 28 +++++++++++++++++++++++++++- web/src/api/WsProvider.tsx | 11 +++++++++++ web/src/hooks/use-stats.ts | 20 ++++++++++++-------- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/frigate/debug_replay.py b/frigate/debug_replay.py index ac04090e47..a0c122134c 100644 --- a/frigate/debug_replay.py +++ b/frigate/debug_replay.py @@ -9,6 +9,7 @@ import logging import os import shutil import threading +import time from ruamel.yaml import YAML @@ -25,7 +26,15 @@ from frigate.const import ( REPLAY_DIR, THUMB_DIR, ) -from frigate.jobs.debug_replay import cancel_debug_replay_job, wait_for_runner +from frigate.jobs.debug_replay import ( + JOB_TYPE as DEBUG_REPLAY_JOB_TYPE, +) +from frigate.jobs.debug_replay import ( + cancel_debug_replay_job, + wait_for_runner, +) +from frigate.jobs.export import JobStatePublisher +from frigate.types import JobStatusTypesEnum from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files from frigate.util.config import find_config_file @@ -49,6 +58,7 @@ class DebugReplayManager: self.clip_path: str | None = None self.start_ts: float | None = None self.end_ts: float | None = None + self._job_state_publisher = JobStatePublisher() @property def active(self) -> bool: @@ -150,6 +160,7 @@ class DebugReplayManager: return replay_name = self.replay_camera_name + source_camera = self.source_camera # Only publish remove if the camera was actually added to the live # config (i.e. the runner reached the starting_camera phase). @@ -163,6 +174,21 @@ class DebugReplayManager: self._cleanup_db(replay_name) self._cleanup_files(replay_name) + self._job_state_publisher.publish( + { + "id": "stopped", + "job_type": DEBUG_REPLAY_JOB_TYPE, + "status": JobStatusTypesEnum.cancelled, + "start_time": None, + "end_time": time.time(), + "error_message": None, + "results": { + "source_camera": source_camera, + "replay_camera_name": replay_name, + }, + } + ) + self._clear_locked() logger.info("Debug replay stopped and cleaned up: %s", replay_name) diff --git a/web/src/api/WsProvider.tsx b/web/src/api/WsProvider.tsx index d00772f9d5..d73e5a3090 100644 --- a/web/src/api/WsProvider.tsx +++ b/web/src/api/WsProvider.tsx @@ -10,15 +10,21 @@ export function WsProvider({ children }: { children: ReactNode }) { const reconnectTimer = useRef | null>(null); const reconnectAttempt = useRef(0); const unmounted = useRef(false); + const pendingSends = useRef>(new Map()); const sendJsonMessage = useCallback((msg: unknown) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify(msg)); + } else if (msg && typeof msg === "object" && "topic" in msg) { + // Sends issued before the socket reaches OPEN (or during a reconnect + // window) are buffered here and flushed in onopen + pendingSends.current.set(String((msg as { topic: unknown }).topic), msg); } }, []); useEffect(() => { unmounted.current = false; + const queue = pendingSends.current; function connect() { if (unmounted.current) return; @@ -31,6 +37,10 @@ export function WsProvider({ children }: { children: ReactNode }) { ws.send( JSON.stringify({ topic: "onConnect", message: "", retain: false }), ); + for (const queued of queue.values()) { + ws.send(JSON.stringify(queued)); + } + queue.clear(); }; ws.onmessage = (event: MessageEvent) => { @@ -64,6 +74,7 @@ export function WsProvider({ children }: { children: ReactNode }) { ws.onerror = null; ws.close(); } + queue.clear(); resetWsStore(); }; }, [wsUrl]); diff --git a/web/src/hooks/use-stats.ts b/web/src/hooks/use-stats.ts index 5a1922a744..b2fe6ca629 100644 --- a/web/src/hooks/use-stats.ts +++ b/web/src/hooks/use-stats.ts @@ -10,7 +10,7 @@ import useSWR from "swr"; import useDeepMemo from "./use-deep-memo"; import { capitalizeAll, capitalizeFirstLetter } from "@/utils/stringUtil"; import { isReplayCamera } from "@/utils/cameraUtil"; -import { useFrigateStats } from "@/api/ws"; +import { useFrigateStats, useJobStatus } from "@/api/ws"; import { useIsAdmin } from "./use-is-admin"; import { useTranslation } from "react-i18next"; @@ -19,11 +19,15 @@ export default function useStats(stats: FrigateStats | undefined) { const { t } = useTranslation(["views/system"]); const { data: config } = useSWR("config"); const isAdmin = useIsAdmin(); - const { data: debugReplayStatus } = useSWR( - isAdmin ? "debug_replay/status" : null, - { - revalidateOnFocus: false, - }, + + // Pass isAdmin as revalidateOnFocus so non-admins never send the jobState snapshot pull + const { payload: replayJob } = useJobStatus("debug_replay", isAdmin); + const replayActive = Boolean( + isAdmin && + replayJob && + (replayJob.status === "queued" || + replayJob.status === "running" || + replayJob.status === "success"), ); const memoizedStats = useDeepMemo(stats); @@ -140,7 +144,7 @@ export default function useStats(stats: FrigateStats | undefined) { }); // Add message if debug replay is active - if (debugReplayStatus?.active) { + if (replayActive) { problems.push({ text: t("stats.debugReplayActive", { defaultValue: "Debug replay session is active", @@ -151,7 +155,7 @@ export default function useStats(stats: FrigateStats | undefined) { } return problems; - }, [config, memoizedStats, t, debugReplayStatus]); + }, [config, memoizedStats, t, replayActive]); return { potentialProblems }; }