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
This commit is contained in:
Josh Hawkins 2026-05-18 21:53:27 -05:00
parent 6e03dc6fff
commit 2c217cc124
3 changed files with 50 additions and 9 deletions

View File

@ -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)

View File

@ -10,15 +10,21 @@ export function WsProvider({ children }: { children: ReactNode }) {
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectAttempt = useRef(0);
const unmounted = useRef(false);
const pendingSends = useRef<Map<string, unknown>>(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]);

View File

@ -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<FrigateConfig>("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 };
}