From 43d97acd21e1f2097b7b39702b28d31975e3c7cd Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 18 May 2026 22:52:40 -0500 Subject: [PATCH] Miscellaneous fixes (#23238) * start audio transcription post processor when enabled on any camera * Fetch embed key whenever an error occurs in case the llama server was restarted * mypy * add tooltips for colored dots in settings menu * add ability to reorder cameras from management pane * add ability to reorder birdseye * add reordering save text to camera management view * Include NPU in latency performance hint * Implement turbo for NPU on object detection * hide order fields * drop auto-derived field paths from camera value when unset globally * use correct field type for export hwaccel args * add debug replay to detail actions menu * clarify debug replay in docs * guard get_current_frame_time against missing camera state * Implement debug reply from export * Refactor debug replay to use sources for dynamic playback * Mypy * fix debug export replay source timestamp handling * skip replay cameras in stats immediately * 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 * fix debug replay test hang --------- Co-authored-by: Nicolas Mowen --- docs/docs/troubleshooting/dummy-camera.md | 2 + frigate/api/debug_replay.py | 103 ++++++++- frigate/api/export.py | 2 +- frigate/debug_replay.py | 28 ++- frigate/detectors/detection_runners.py | 15 +- frigate/embeddings/maintainer.py | 2 +- frigate/events/audio.py | 7 +- frigate/genai/llama_cpp.py | 116 +++++++--- frigate/jobs/debug_replay.py | 178 +++++++++++---- .../test/http_api/test_debug_replay_api.py | 7 +- frigate/test/test_debug_replay.py | 8 + frigate/test/test_debug_replay_job.py | 55 ++--- frigate/test/test_media_auth.py | 2 +- frigate/track/object_processing.py | 3 + web/public/locales/en/views/explore.json | 2 +- web/public/locales/en/views/settings.json | 17 +- web/src/api/WsProvider.tsx | 11 + web/src/components/card/ExportCard.tsx | 72 +++++- .../config-form/section-configs/birdseye.ts | 3 +- .../config-form/section-configs/record.ts | 6 +- .../config-form/section-configs/ui.ts | 2 +- .../sectionExtras/BirdseyeCameraReorder.tsx | 213 +++++++++++++++++ .../config-form/sectionExtras/registry.ts | 4 + .../overlay/detail/DetailActionsMenu.tsx | 78 ++++++- web/src/hooks/use-config-override.ts | 102 ++++++++- web/src/hooks/use-stats.ts | 32 +-- web/src/pages/Settings.tsx | 61 ++++- .../views/settings/CameraManagementView.tsx | 215 ++++++++++++++++-- 28 files changed, 1176 insertions(+), 170 deletions(-) create mode 100644 web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx diff --git a/docs/docs/troubleshooting/dummy-camera.md b/docs/docs/troubleshooting/dummy-camera.md index e24c821290..aed0af5e68 100644 --- a/docs/docs/troubleshooting/dummy-camera.md +++ b/docs/docs/troubleshooting/dummy-camera.md @@ -37,6 +37,8 @@ The per-clip variation is typically quite low and is mostly an artifact of keyfr Debug Replay lets you re-run Frigate's detection pipeline against a section of recorded video without manually configuring a dummy camera. It automatically extracts the recording, creates a temporary camera with the same detection settings as the original, and loops the clip through the pipeline so you can observe detections in real time. +Debug Replay isn't intended to be a one-stop pane for all Frigate diagnostics or a comprehensive debugging environment for every Frigate feature. It merely makes it easier to spin up a "dummy camera" and perform some common adjustments in real-time. You'll still need to use the normal tools (logs, an MQTT client, etc) to debug your feature. + ### When to use - Reproducing a detection or tracking issue from a specific time range diff --git a/frigate/api/debug_replay.py b/frigate/api/debug_replay.py index 171bf1b98a..2ba5d2b85f 100644 --- a/frigate/api/debug_replay.py +++ b/frigate/api/debug_replay.py @@ -6,11 +6,18 @@ from datetime import datetime from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse +from peewee import DoesNotExist from pydantic import BaseModel, Field from frigate.api.auth import require_role from frigate.api.defs.tags import Tags -from frigate.jobs.debug_replay import start_debug_replay_job +from frigate.jobs.debug_replay import ( + ExportDebugReplaySource, + RecordingDebugReplaySource, + start_debug_replay_job, +) +from frigate.models import Export +from frigate.util.services import get_video_properties logger = logging.getLogger(__name__) @@ -25,6 +32,12 @@ class DebugReplayStartBody(BaseModel): end_time: float = Field(title="End timestamp") +class DebugReplayStartFromExportBody(BaseModel): + """Request body for starting a debug replay session from an export.""" + + export_id: str = Field(title="Export id") + + class DebugReplayStartResponse(BaseModel): """Response for starting a debug replay session.""" @@ -73,13 +86,95 @@ class DebugReplayStopResponse(BaseModel): async def start_debug_replay(request: Request, body: DebugReplayStartBody): """Start a debug replay session asynchronously.""" replay_manager = request.app.replay_manager + source = RecordingDebugReplaySource( + source_camera=body.camera, + start_ts=body.start_time, + end_ts=body.end_time, + ) try: job_id = await asyncio.to_thread( start_debug_replay_job, - source_camera=body.camera, - start_ts=body.start_time, - end_ts=body.end_time, + source=source, + frigate_config=request.app.frigate_config, + config_publisher=request.app.config_publisher, + replay_manager=replay_manager, + ) + except RuntimeError: + return JSONResponse( + content={ + "success": False, + "message": "A replay session is already active", + }, + status_code=409, + ) + except ValueError: + logger.exception("Rejected debug replay start request") + return JSONResponse( + content={ + "success": False, + "message": "Invalid debug replay parameters", + }, + status_code=400, + ) + + return JSONResponse( + content={ + "success": True, + "replay_camera": replay_manager.replay_camera_name, + "job_id": job_id, + }, + status_code=202, + ) + + +@router.post( + "/debug_replay/start_from_export", + response_model=DebugReplayStartResponse, + status_code=202, + responses={ + 400: {"description": "Invalid export, time range, or no recordings"}, + 404: {"description": "Export not found"}, + 409: {"description": "A replay session is already active"}, + }, + dependencies=[Depends(require_role(["admin"]))], + summary="Start debug replay from an export", + description="Start a debug replay session covering an existing export's " + "time range. The end time is derived from the export's video duration.", +) +async def start_debug_replay_from_export( + request: Request, body: DebugReplayStartFromExportBody +): + """Start a debug replay session from an existing export.""" + try: + export: Export = Export.get(Export.id == body.export_id) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Export not found"}, + status_code=404, + ) + + properties = await get_video_properties( + request.app.frigate_config.ffmpeg, export.video_path, get_duration=True + ) + duration = properties.get("duration", -1) + + if duration is None or duration <= 0: + return JSONResponse( + content={ + "success": False, + "message": "Could not determine export duration", + }, + status_code=400, + ) + + replay_manager = request.app.replay_manager + source = ExportDebugReplaySource(export=export, duration=float(duration)) + + try: + job_id = await asyncio.to_thread( + start_debug_replay_job, + source=source, frigate_config=request.app.frigate_config, config_publisher=request.app.config_publisher, replay_manager=replay_manager, diff --git a/frigate/api/export.py b/frigate/api/export.py index 2f4ca78da0..09ded84124 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -398,7 +398,7 @@ class _StreamingZipBuffer: def _unique_archive_name(export: Export, used: set[str]) -> str: base = sanitize_filename(export.name) if export.name else None if not base: - base = f"{export.camera}_{int(datetime.datetime.timestamp(export.date))}" + base = f"{export.camera}_{int(export.date)}" candidate = f"{base}.mp4" counter = 1 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/frigate/detectors/detection_runners.py b/frigate/detectors/detection_runners.py index 39ebbf5783..c89cd8b44f 100644 --- a/frigate/detectors/detection_runners.py +++ b/frigate/detectors/detection_runners.py @@ -282,6 +282,13 @@ class OpenVINOModelRunner(BaseModelRunner): EnrichmentModelTypeEnum.arcface.value, ] + @staticmethod + def is_detection_model(model_type: str) -> bool: + # Import here to avoid circular imports + from frigate.detectors.detector_config import ModelTypeEnum + + return model_type in [m.value for m in ModelTypeEnum] + def __init__(self, model_path: str, device: str, model_type: str, **kwargs): self.model_path = model_path self.device = device @@ -310,9 +317,15 @@ class OpenVINOModelRunner(BaseModelRunner): # Apply performance optimization self.ov_core.set_property(device, {"PERF_COUNT": "NO"}) - if device in ["GPU", "AUTO"]: + if device in ["GPU", "AUTO", "NPU"]: self.ov_core.set_property(device, {"PERFORMANCE_HINT": "LATENCY"}) + if device == "NPU" and OpenVINOModelRunner.is_detection_model(model_type): + try: + self.ov_core.set_property(device, {"NPU_TURBO": "YES"}) + except Exception as e: + logger.debug(f"NPU_TURBO not supported by driver: {e}") + # Compile model self.compiled_model = self.ov_core.compile_model( model=model_path, device_name=device diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index 96a44a8c6c..48c5cdd79b 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -232,7 +232,7 @@ class EmbeddingMaintainer(threading.Thread): ) ) - if self.config.audio_transcription.enabled and any( + if any( c.enabled_in_config and c.audio_transcription.enabled for c in self.config.cameras.values() ): diff --git a/frigate/events/audio.py b/frigate/events/audio.py index c5e35a8d52..b90b795778 100644 --- a/frigate/events/audio.py +++ b/frigate/events/audio.py @@ -100,7 +100,10 @@ class AudioProcessor(FrigateProcess): threading.current_thread().name = "process:audio_manager" - if self.config.audio_transcription.enabled: + if any( + c.enabled_in_config and c.audio_transcription.enabled + for c in self.config.cameras.values() + ): self.transcription_model_runner: AudioTranscriptionModelRunner | None = ( AudioTranscriptionModelRunner( self.config.audio_transcription.device or "AUTO", @@ -206,7 +209,7 @@ class AudioEventMaintainer(threading.Thread): self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio.value) if ( - self.config.audio_transcription.enabled + self.camera_config.audio_transcription.enabled and self.audio_transcription_model_runner is not None ): # init the transcription processor for this camera diff --git a/frigate/genai/llama_cpp.py b/frigate/genai/llama_cpp.py index c935207bfe..86db201288 100644 --- a/frigate/genai/llama_cpp.py +++ b/frigate/genai/llama_cpp.py @@ -4,7 +4,7 @@ import base64 import io import json import logging -from typing import Any, AsyncGenerator, Optional +from typing import Any, AsyncGenerator, Optional, cast import httpx import numpy as np @@ -75,6 +75,29 @@ def _parse_launch_arg(args: list[str], flag: str) -> str | None: return args[idx + 1] +def _fetch_llama_props(base_url: str, model: str) -> dict[str, Any]: + """Fetch /props from a llama.cpp server, with llama-swap fallback. + + Raises the underlying RequestException if both endpoints fail; callers + decide how to surface the failure. + """ + try: + response = requests.get( + f"{base_url}/props", + params={"model": model}, + timeout=10, + ) + response.raise_for_status() + return cast(dict[str, Any], response.json()) + except Exception: + response = requests.get( + f"{base_url}/upstream/{model}/props", + timeout=10, + ) + response.raise_for_status() + return cast(dict[str, Any], response.json()) + + def _to_jpeg(img_bytes: bytes) -> bytes | None: """Convert image bytes to JPEG. llama.cpp/STB does not support WebP.""" try: @@ -239,21 +262,7 @@ class LlamaCppClient(GenAIClient): info["supports_tools"] = True try: - try: - response = requests.get( - f"{base_url}/props", - params={"model": configured_model}, - timeout=10, - ) - response.raise_for_status() - props = response.json() - except Exception: - response = requests.get( - f"{base_url}/upstream/{configured_model}/props", - timeout=10, - ) - response.raise_for_status() - props = response.json() + props = _fetch_llama_props(base_url, configured_model) if info["context_size"] is None: default_settings = props.get("default_generation_settings", {}) @@ -559,6 +568,31 @@ class LlamaCppClient(GenAIClient): ) return result if result else None + def _refresh_media_marker(self) -> bool: + """Re-fetch /props and update the cached media marker if it changed. + + The server randomizes the marker per startup (unless LLAMA_MEDIA_MARKER + is set), so a stale marker indicates a restart. Returns True iff the + marker was updated to a new value — used to gate a one-shot retry of + a failed embeddings request. + """ + if self.provider is None: + return False + try: + props = _fetch_llama_props(self.provider, self.genai_config.model) + except Exception as e: + logger.warning("Failed to refresh llama.cpp media marker: %s", e) + return False + + marker = props.get("media_marker") + + if not isinstance(marker, str) or not marker or marker == self._media_marker: + return False + + logger.info("llama.cpp media marker changed (server restart); refreshed") + self._media_marker = marker + return True + def embed( self, texts: list[str] | None = None, @@ -583,30 +617,46 @@ class LlamaCppClient(GenAIClient): EMBEDDING_DIM = 768 - content = [] - for text in texts: - content.append({"prompt_string": text}) + encoded_images: list[str] = [] for img in images: # llama.cpp uses STB which does not support WebP; convert to JPEG jpeg_bytes = _to_jpeg(img) to_encode = jpeg_bytes if jpeg_bytes is not None else img - encoded = base64.b64encode(to_encode).decode("utf-8") - # prompt_string must contain the server's media marker placeholder. - # The marker is randomized per server startup (read from /props). - content.append( - { - "prompt_string": f"{self._media_marker}\n", - "multimodal_data": [encoded], # type: ignore[dict-item] - } + encoded_images.append(base64.b64encode(to_encode).decode("utf-8")) + + def build_content() -> list[dict[str, Any]]: + # prompt_string must contain the server's media marker placeholder + # for each image. The marker is randomized per server startup. + content: list[dict[str, Any]] = [] + for text in texts: + content.append({"prompt_string": text}) + for encoded in encoded_images: + content.append( + { + "prompt_string": f"{self._media_marker}\n", + "multimodal_data": [encoded], + } + ) + return content + + def post_embeddings() -> requests.Response: + return requests.post( + f"{self.provider}/embeddings", + json={"model": self.genai_config.model, "content": build_content()}, + timeout=self.timeout, ) try: - response = requests.post( - f"{self.provider}/embeddings", - json={"model": self.genai_config.model, "content": content}, - timeout=self.timeout, - ) - response.raise_for_status() + try: + response = post_embeddings() + response.raise_for_status() + except requests.exceptions.RequestException: + # The server may have restarted with a new media marker. + # Refresh from /props; only retry if the marker actually changed. + if not encoded_images or not self._refresh_media_marker(): + raise + response = post_embeddings() + response.raise_for_status() result = response.json() items = result.get("data", result) if isinstance(result, dict) else result diff --git a/frigate/jobs/debug_replay.py b/frigate/jobs/debug_replay.py index 0616c46290..3dd7a02bf6 100644 --- a/frigate/jobs/debug_replay.py +++ b/frigate/jobs/debug_replay.py @@ -12,6 +12,7 @@ import os import subprocess as sp import threading import time +from abc import ABC, abstractmethod from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Optional, cast @@ -23,7 +24,7 @@ from frigate.const import REPLAY_CAMERA_PREFIX, REPLAY_DIR from frigate.jobs.export import JobStatePublisher from frigate.jobs.job import Job from frigate.jobs.manager import job_is_running, set_current_job -from frigate.models import Recordings +from frigate.models import Export, Recordings from frigate.types import JobStatusTypesEnum from frigate.util.ffmpeg import run_ffmpeg_with_progress @@ -114,6 +115,125 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode return cast(ModelSelect, query) +class DebugReplaySource(ABC): + """Abstract source for a debug replay session. + + Provides the camera identity and time range the replay represents, + validates that usable content exists, and supplies the ffmpeg input + args used to build the replay clip. + """ + + @property + @abstractmethod + def source_camera(self) -> str: + """Camera name the replay is derived from.""" + + @property + @abstractmethod + def start_ts(self) -> float: + """Unix timestamp marking the start of the replay range.""" + + @property + @abstractmethod + def end_ts(self) -> float: + """Unix timestamp marking the end of the replay range.""" + + @abstractmethod + def validate(self) -> None: + """Raise ValueError if the source has no usable content.""" + + @abstractmethod + def ffmpeg_input_args(self, working_dir: str) -> list[str]: + """Return ffmpeg input args (including -i). May write temp files in working_dir.""" + + def cleanup(self, working_dir: str) -> None: + """Remove any temp files the source created in working_dir. Default no-op.""" + + +class RecordingDebugReplaySource(DebugReplaySource): + """Replay source backed by the Recordings table. + + Builds a concat playlist of recording files covering the time range + and feeds it to ffmpeg's concat demuxer. + """ + + def __init__(self, source_camera: str, start_ts: float, end_ts: float) -> None: + self._camera = source_camera + self._start_ts = start_ts + self._end_ts = end_ts + self._concat_file: Optional[str] = None + + @property + def source_camera(self) -> str: + return self._camera + + @property + def start_ts(self) -> float: + return self._start_ts + + @property + def end_ts(self) -> float: + return self._end_ts + + def validate(self) -> None: + if self._end_ts <= self._start_ts: + raise ValueError("End time must be after start time") + + if not query_recordings(self._camera, self._start_ts, self._end_ts).count(): + raise ValueError( + f"No recordings found for camera '{self._camera}' in the specified time range" + ) + + def ffmpeg_input_args(self, working_dir: str) -> list[str]: + replay_name = f"{REPLAY_CAMERA_PREFIX}{self._camera}" + concat_file = os.path.join(working_dir, f"{replay_name}_concat.txt") + recordings = query_recordings(self._camera, self._start_ts, self._end_ts) + with open(concat_file, "w") as f: + for recording in recordings: + f.write(f"file '{recording.path}'\n") + self._concat_file = concat_file + return ["-f", "concat", "-safe", "0", "-i", concat_file] + + def cleanup(self, working_dir: str) -> None: + if self._concat_file: + _remove_silent(self._concat_file) + + +class ExportDebugReplaySource(DebugReplaySource): + """Replay source backed by an existing Export. + + Uses the export's video file directly as the ffmpeg input — does not + require recordings to still exist for the time range. + """ + + def __init__(self, export: Export, duration: float) -> None: + self._camera = cast(str, export.camera) + # Export.date is declared DateTimeField but Frigate writes raw unix + # timestamps to the column. + self._start_ts = float(cast(Any, export.date)) + self._video_path = cast(str, export.video_path) + self._duration = duration + + @property + def source_camera(self) -> str: + return self._camera + + @property + def start_ts(self) -> float: + return self._start_ts + + @property + def end_ts(self) -> float: + return self._start_ts + self._duration + + def validate(self) -> None: + if not os.path.exists(self._video_path): + raise ValueError(f"Export video file not found: {self._video_path}") + + def ffmpeg_input_args(self, working_dir: str) -> list[str]: + return ["-i", self._video_path] + + class DebugReplayJobRunner(threading.Thread): """Worker thread that drives the startup job to completion. @@ -126,6 +246,7 @@ class DebugReplayJobRunner(threading.Thread): def __init__( self, job: DebugReplayJob, + source: DebugReplaySource, frigate_config: FrigateConfig, config_publisher: CameraConfigUpdatePublisher, replay_manager: "DebugReplayManager", @@ -133,6 +254,7 @@ class DebugReplayJobRunner(threading.Thread): ) -> None: super().__init__(daemon=True, name=f"debug_replay_{job.id}") self.job = job + self.source = source self.frigate_config = frigate_config self.config_publisher = config_publisher self.replay_manager = replay_manager @@ -183,7 +305,6 @@ class DebugReplayJobRunner(threading.Thread): def run(self) -> None: replay_name = self.job.replay_camera_name os.makedirs(REPLAY_DIR, exist_ok=True) - concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt") clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4") self.job.status = JobStatusTypesEnum.running @@ -192,23 +313,13 @@ class DebugReplayJobRunner(threading.Thread): self._broadcast(force=True) try: - recordings = query_recordings( - self.job.source_camera, self.job.start_ts, self.job.end_ts - ) - with open(concat_file, "w") as f: - for recording in recordings: - f.write(f"file '{recording.path}'\n") + input_args = self.source.ffmpeg_input_args(REPLAY_DIR) ffmpeg_cmd = [ self.frigate_config.ffmpeg.ffmpeg_path, "-hide_banner", "-y", - "-f", - "concat", - "-safe", - "0", - "-i", - concat_file, + *input_args, "-c", "copy", "-movflags", @@ -285,7 +396,7 @@ class DebugReplayJobRunner(threading.Thread): self.replay_manager.clear_session() _remove_silent(clip_path) finally: - _remove_silent(concat_file) + self.source.cleanup(REPLAY_DIR) _set_active_runner(None) def _finalize_cancelled(self, clip_path: str) -> None: @@ -309,52 +420,43 @@ def _remove_silent(path: str) -> None: def start_debug_replay_job( *, - source_camera: str, - start_ts: float, - end_ts: float, + source: DebugReplaySource, frigate_config: FrigateConfig, config_publisher: CameraConfigUpdatePublisher, replay_manager: "DebugReplayManager", ) -> str: """Validate, create job, start runner. Returns the job id. - Raises ValueError for bad params (camera missing, time range - invalid, no recordings) and RuntimeError if a session is already - active. + Raises ValueError for an invalid source (camera missing, source has + no usable content) and RuntimeError if a session is already active. """ if job_is_running(JOB_TYPE) or replay_manager.active: raise RuntimeError("A replay session is already active") - if source_camera not in frigate_config.cameras: - raise ValueError(f"Camera '{source_camera}' not found") + if source.source_camera not in frigate_config.cameras: + raise ValueError(f"Camera '{source.source_camera}' not found") - if end_ts <= start_ts: - raise ValueError("End time must be after start time") + source.validate() - recordings = query_recordings(source_camera, start_ts, end_ts) - if not recordings.count(): - raise ValueError( - f"No recordings found for camera '{source_camera}' in the specified time range" - ) - - replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}" + replay_name = f"{REPLAY_CAMERA_PREFIX}{source.source_camera}" replay_manager.mark_starting( - source_camera=source_camera, + source_camera=source.source_camera, replay_camera_name=replay_name, - start_ts=start_ts, - end_ts=end_ts, + start_ts=source.start_ts, + end_ts=source.end_ts, ) job = DebugReplayJob( - source_camera=source_camera, + source_camera=source.source_camera, replay_camera_name=replay_name, - start_ts=start_ts, - end_ts=end_ts, + start_ts=source.start_ts, + end_ts=source.end_ts, ) set_current_job(job) runner = DebugReplayJobRunner( job=job, + source=source, frigate_config=frigate_config, config_publisher=config_publisher, replay_manager=replay_manager, diff --git a/frigate/test/http_api/test_debug_replay_api.py b/frigate/test/http_api/test_debug_replay_api.py index 45c2c5478f..be4e7f496f 100644 --- a/frigate/test/http_api/test_debug_replay_api.py +++ b/frigate/test/http_api/test_debug_replay_api.py @@ -15,11 +15,12 @@ class TestDebugReplayAPI(BaseTestHttp): # Stub the factory to skip validation/threading and just record the # name on the manager the way the real factory's mark_starting would. def fake_start(**kwargs): + source = kwargs["source"] kwargs["replay_manager"].mark_starting( - source_camera=kwargs["source_camera"], + source_camera=source.source_camera, replay_camera_name="_replay_front", - start_ts=kwargs["start_ts"], - end_ts=kwargs["end_ts"], + start_ts=source.start_ts, + end_ts=source.end_ts, ) return "job-1234" diff --git a/frigate/test/test_debug_replay.py b/frigate/test/test_debug_replay.py index e7f9df42db..a91f759c44 100644 --- a/frigate/test/test_debug_replay.py +++ b/frigate/test/test_debug_replay.py @@ -71,6 +71,14 @@ class TestDebugReplayManagerSession(unittest.TestCase): class TestDebugReplayManagerStop(unittest.TestCase): + def setUp(self) -> None: + # stop() publishes a terminal job_state via a real JobStatePublisher, + # which opens a ZMQ REQ socket and blocks on REP. No dispatcher runs + # in unit tests, so substitute a no-op publisher. + patcher = patch("frigate.debug_replay.JobStatePublisher") + patcher.start() + self.addCleanup(patcher.stop) + def test_stop_when_inactive_is_a_noop(self) -> None: from frigate.debug_replay import DebugReplayManager diff --git a/frigate/test/test_debug_replay_job.py b/frigate/test/test_debug_replay_job.py index 60997564f4..5e2da16720 100644 --- a/frigate/test/test_debug_replay_job.py +++ b/frigate/test/test_debug_replay_job.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch from frigate.debug_replay import DebugReplayManager from frigate.jobs.debug_replay import ( DebugReplayJob, + RecordingDebugReplaySource, cancel_debug_replay_job, get_active_runner, start_debug_replay_job, @@ -99,9 +100,9 @@ class TestStartDebugReplayJob(unittest.TestCase): def test_rejects_unknown_camera(self) -> None: with self.assertRaises(ValueError): start_debug_replay_job( - source_camera="missing", - start_ts=100.0, - end_ts=200.0, + source=RecordingDebugReplaySource( + source_camera="missing", start_ts=100.0, end_ts=200.0 + ), frigate_config=self.frigate_config, config_publisher=self.publisher, replay_manager=self.manager, @@ -110,9 +111,9 @@ class TestStartDebugReplayJob(unittest.TestCase): def test_rejects_invalid_time_range(self) -> None: with self.assertRaises(ValueError): start_debug_replay_job( - source_camera="front", - start_ts=200.0, - end_ts=100.0, + source=RecordingDebugReplaySource( + source_camera="front", start_ts=200.0, end_ts=100.0 + ), frigate_config=self.frigate_config, config_publisher=self.publisher, replay_manager=self.manager, @@ -124,9 +125,9 @@ class TestStartDebugReplayJob(unittest.TestCase): with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs): with self.assertRaises(ValueError): start_debug_replay_job( - source_camera="front", - start_ts=100.0, - end_ts=200.0, + source=RecordingDebugReplaySource( + source_camera="front", start_ts=100.0, end_ts=200.0 + ), frigate_config=self.frigate_config, config_publisher=self.publisher, replay_manager=self.manager, @@ -154,9 +155,9 @@ class TestStartDebugReplayJob(unittest.TestCase): patch("builtins.open", unittest.mock.mock_open()), ): job_id = start_debug_replay_job( - source_camera="front", - start_ts=100.0, - end_ts=200.0, + source=RecordingDebugReplaySource( + source_camera="front", start_ts=100.0, end_ts=200.0 + ), frigate_config=self.frigate_config, config_publisher=self.publisher, replay_manager=self.manager, @@ -191,9 +192,9 @@ class TestStartDebugReplayJob(unittest.TestCase): patch("builtins.open", unittest.mock.mock_open()), ): start_debug_replay_job( - source_camera="front", - start_ts=100.0, - end_ts=200.0, + source=RecordingDebugReplaySource( + source_camera="front", start_ts=100.0, end_ts=200.0 + ), frigate_config=self.frigate_config, config_publisher=self.publisher, replay_manager=self.manager, @@ -201,9 +202,9 @@ class TestStartDebugReplayJob(unittest.TestCase): with self.assertRaises(RuntimeError): start_debug_replay_job( - source_camera="front", - start_ts=100.0, - end_ts=200.0, + source=RecordingDebugReplaySource( + source_camera="front", start_ts=100.0, end_ts=200.0 + ), frigate_config=self.frigate_config, config_publisher=self.publisher, replay_manager=self.manager, @@ -269,9 +270,9 @@ class TestRunnerHappyPath(unittest.TestCase): patch("builtins.open", unittest.mock.mock_open()), ): start_debug_replay_job( - source_camera="front", - start_ts=100.0, - end_ts=200.0, + source=RecordingDebugReplaySource( + source_camera="front", start_ts=100.0, end_ts=200.0 + ), frigate_config=self.frigate_config, config_publisher=self.publisher, replay_manager=self.manager, @@ -340,9 +341,9 @@ class TestRunnerFailurePath(unittest.TestCase): patch("builtins.open", unittest.mock.mock_open()), ): start_debug_replay_job( - source_camera="front", - start_ts=100.0, - end_ts=200.0, + source=RecordingDebugReplaySource( + source_camera="front", start_ts=100.0, end_ts=200.0 + ), frigate_config=self.frigate_config, config_publisher=self.publisher, replay_manager=self.manager, @@ -418,9 +419,9 @@ class TestRunnerCancellation(unittest.TestCase): patch("builtins.open", unittest.mock.mock_open()), ): start_debug_replay_job( - source_camera="front", - start_ts=100.0, - end_ts=200.0, + source=RecordingDebugReplaySource( + source_camera="front", start_ts=100.0, end_ts=200.0 + ), frigate_config=self.frigate_config, config_publisher=self.publisher, replay_manager=self.manager, diff --git a/frigate/test/test_media_auth.py b/frigate/test/test_media_auth.py index 80dc6fb8b0..d025fea614 100644 --- a/frigate/test/test_media_auth.py +++ b/frigate/test/test_media_auth.py @@ -230,7 +230,7 @@ class TestExportResolution(unittest.TestCase): id=export_id, camera=camera, name=f"export-{export_id}", - date=datetime.datetime.now(), + date=int(datetime.datetime.now().timestamp()), video_path=f"/media/frigate/exports/{filename}", thumb_path=f"/media/frigate/exports/{filename}.jpg", in_progress=False, diff --git a/frigate/track/object_processing.py b/frigate/track/object_processing.py index 3f8b5e626d..5832d8cdb8 100644 --- a/frigate/track/object_processing.py +++ b/frigate/track/object_processing.py @@ -357,6 +357,9 @@ class TrackedObjectProcessor(threading.Thread): def get_current_frame_time(self, camera: str) -> float: """Returns the latest frame time for a given camera.""" + if camera not in self.camera_states: + return 0.0 + return self.camera_states[camera].current_frame_time def set_sub_label( diff --git a/web/public/locales/en/views/explore.json b/web/public/locales/en/views/explore.json index 43db9bda48..d1087b3c96 100644 --- a/web/public/locales/en/views/explore.json +++ b/web/public/locales/en/views/explore.json @@ -222,7 +222,7 @@ "label": "Hide object path" }, "debugReplay": { - "label": "Debug replay", + "label": "Debug Replay", "aria": "View this tracked object in the debug replay view" }, "more": { diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 0255082f1f..477f6e0f90 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -40,6 +40,11 @@ "profilePrefix": "{{profile}} profile: {{fields}}" } }, + "menuDot": { + "overrideGlobal": "This section overrides the global configuration", + "overrideProfile": "This section is overridden by the {{profile}} profile", + "unsaved": "This section has unsaved changes" + }, "menu": { "general": "General", "globalConfig": "Global configuration", @@ -472,10 +477,13 @@ "streams": { "title": "Enable / Disable Cameras", "enableLabel": "Enabled cameras", - "enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.
Note: This does not disable go2rtc restreams.", + "enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.
Note: This does not disable go2rtc restreams.

Drag the handle to reorder the cameras as they appear in the UI. The order of enabled cameras will be reflected throughout the UI including the Live dashboard and camera selection dropdowns.", "disableLabel": "Disabled cameras", "disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.", "enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.", + "reorderHandle": "Drag to reorder", + "saving": "Saving…", + "saved": "Saved", "friendlyName": { "edit": "Edit camera display name", "title": "Edit Display Name", @@ -1682,6 +1690,13 @@ "objects": "Objects", "motion": "Motion", "continuous": "Continuous" + }, + "cameraOrder": { + "label": "Camera order", + "description": "Drag cameras to set their order in the Birdseye layout.", + "reorderHandle": "Drag to reorder", + "saving": "Saving…", + "saved": "Saved" } }, "retainMode": { 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/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index 893f251f8f..58600ed3fd 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -32,6 +32,9 @@ import { FaFolder, FaVideo } from "react-icons/fa"; import { HiSquare2Stack } from "react-icons/hi2"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import useContextMenu from "@/hooks/use-contextmenu"; +import axios from "axios"; +import { toast } from "sonner"; +import { useNavigate } from "react-router-dom"; type CaseCardProps = { className: string; @@ -123,11 +126,63 @@ export function ExportCard({ onAssignToCase, onRemoveFromCase, }: ExportCardProps) { - const { t } = useTranslation(["views/exports"]); + const { t } = useTranslation(["views/exports", "views/replay"]); + const navigate = useNavigate(); const isAdmin = useIsAdmin(); const [loading, setLoading] = useState( exportedRecording.thumb_path.length > 0, ); + const [isStartingReplay, setIsStartingReplay] = useState(false); + + const handleDebugReplay = useCallback(() => { + setIsStartingReplay(true); + + axios + .post("debug_replay/start_from_export", { + export_id: exportedRecording.id, + }) + .then((response) => { + if (response.status === 202 || response.status === 200) { + navigate("/replay"); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + + if (error.response?.status === 409) { + toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), { + position: "top-center", + closeButton: true, + dismissible: false, + action: ( + + + + ), + }); + } else { + toast.error( + t("dialog.toast.error", { + ns: "views/replay", + error: errorMessage, + }), + { position: "top-center" }, + ); + } + }) + .finally(() => { + setIsStartingReplay(false); + }); + }, [exportedRecording.id, navigate, t]); // Resync the skeleton state whenever the backing export changes. The // list keys by id now, so in practice the component remounts instead @@ -301,6 +356,21 @@ export function ExportCard({ {t("tooltip.downloadVideo")} + {isAdmin && ( + { + e.stopPropagation(); + handleDebugReplay(); + }} + > + {isStartingReplay + ? t("dialog.starting", { ns: "views/replay" }) + : t("title", { ns: "views/replay" })} + + )} {isAdmin && onAssignToCase && ( ("config"); + + const birdseyeCameras = useMemo(() => { + if (!config) return []; + return Object.keys(config.cameras) + .filter( + (name) => + config.cameras[name].enabled_in_config && + config.cameras[name].birdseye?.enabled !== false, + ) + .sort((a, b) => { + const orderA = config.cameras[a].birdseye?.order ?? 0; + const orderB = config.cameras[b].birdseye?.order ?? 0; + if (orderA !== orderB) return orderA - orderB; + return a.localeCompare(b); + }); + }, [config]); + + const [orderedCameras, setOrderedCameras] = + useState(birdseyeCameras); + const orderedCamerasRef = useRef(orderedCameras); + useEffect(() => { + orderedCamerasRef.current = orderedCameras; + }, [orderedCameras]); + + useEffect(() => { + setOrderedCameras((prev) => { + if ( + prev.length === birdseyeCameras.length && + prev.every((cam, i) => cam === birdseyeCameras[i]) + ) { + return prev; + } + return birdseyeCameras; + }); + }, [birdseyeCameras]); + + const [saveStatus, setSaveStatus] = useState("idle"); + const savedResetTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (savedResetTimerRef.current) { + clearTimeout(savedResetTimerRef.current); + } + }; + }, []); + + const handleDragEnd = useCallback(async () => { + const current = orderedCamerasRef.current; + if ( + current.length === birdseyeCameras.length && + current.every((cam, i) => cam === birdseyeCameras[i]) + ) { + return; + } + + const cameraUpdates: Record = {}; + current.forEach((cam, i) => { + cameraUpdates[cam] = { birdseye: { order: i * 10 } }; + }); + + if (savedResetTimerRef.current) { + clearTimeout(savedResetTimerRef.current); + savedResetTimerRef.current = null; + } + setSaveStatus("saving"); + + try { + await axios.put("config/set", { + requires_restart: 0, + config_data: { cameras: cameraUpdates }, + }); + await updateConfig(); + setSaveStatus("saved"); + savedResetTimerRef.current = setTimeout(() => { + setSaveStatus("idle"); + savedResetTimerRef.current = null; + }, SAVED_INDICATOR_MS); + } catch (error) { + setOrderedCameras(birdseyeCameras); + setSaveStatus("idle"); + const errorMessage = + axios.isAxiosError(error) && + (error.response?.data?.message || error.response?.data?.detail) + ? error.response?.data?.message || error.response?.data?.detail + : t("toast.save.error.noMessage", { ns: "common" }); + + toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), { + position: "top-center", + }); + } + }, [birdseyeCameras, updateConfig, t]); + + if (formContext?.level && formContext.level !== "global") { + return null; + } + + if (!config || birdseyeCameras.length < 2) { + return null; + } + + return ( + + + {orderedCameras.map((camera) => ( + + ))} + + + + } + /> + ); +} + +type SaveStatusIndicatorProps = { + status: SaveStatus; +}; + +function SaveStatusIndicator({ status }: SaveStatusIndicatorProps) { + const { t } = useTranslation(["views/settings"]); + return ( +
+ {status === "saving" && ( + + {t("birdseye.cameraOrder.saving")} + + )} + {status === "saved" && ( + + + {t("birdseye.cameraOrder.saved")} + + )} +
+ ); +} + +type BirdseyeCameraRowProps = { + camera: string; + onDragEnd: () => void; +}; + +function BirdseyeCameraRow({ camera, onDragEnd }: BirdseyeCameraRowProps) { + const { t } = useTranslation(["views/settings"]); + const controls = useDragControls(); + + return ( + + + + + ); +} diff --git a/web/src/components/config-form/sectionExtras/registry.ts b/web/src/components/config-form/sectionExtras/registry.ts index 08e3dd86a8..26cd48dbdb 100644 --- a/web/src/components/config-form/sectionExtras/registry.ts +++ b/web/src/components/config-form/sectionExtras/registry.ts @@ -3,6 +3,7 @@ import SemanticSearchReindex from "./SemanticSearchReindex.tsx"; import CameraReviewStatusToggles from "./CameraReviewStatusToggles"; import ProxyRoleMap from "./ProxyRoleMap"; import NotificationsSettingsExtras from "./NotificationsSettingsExtras"; +import BirdseyeCameraReorder from "./BirdseyeCameraReorder"; import type { ConfigFormContext } from "@/types/configForm"; // Props that will be injected into all section renderers @@ -52,6 +53,9 @@ export const sectionRenderers: SectionRenderers = { notifications: { NotificationsSettingsExtras, }, + birdseye: { + BirdseyeCameraReorder, + }, }; export default sectionRenderers; diff --git a/web/src/components/overlay/detail/DetailActionsMenu.tsx b/web/src/components/overlay/detail/DetailActionsMenu.tsx index dc4ea5b2cf..789f396772 100644 --- a/web/src/components/overlay/detail/DetailActionsMenu.tsx +++ b/web/src/components/overlay/detail/DetailActionsMenu.tsx @@ -1,4 +1,6 @@ -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; +import axios from "axios"; +import { toast } from "sonner"; import { Event } from "@/types/event"; import { baseUrl } from "@/api/baseUrl"; import { ReviewSegment, REVIEW_PADDING } from "@/types/review"; @@ -12,6 +14,7 @@ import { DropdownMenuTrigger, DropdownMenuPortal, } from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; import { HiDotsHorizontal } from "react-icons/hi"; import { SearchResult } from "@/types/search"; import { FrigateConfig } from "@/types/frigateConfig"; @@ -33,9 +36,14 @@ export default function DetailActionsMenu({ setSearch, setSimilarity, }: Props) { - const { t } = useTranslation(["views/explore", "views/faceLibrary"]); + const { t } = useTranslation([ + "views/explore", + "views/faceLibrary", + "views/replay", + ]); const navigate = useNavigate(); const [isOpen, setIsOpen] = useState(false); + const [isStarting, setIsStarting] = useState(false); const isAdmin = useIsAdmin(); const clipTimeRange = useMemo(() => { @@ -49,6 +57,54 @@ export default function DetailActionsMenu({ search.data?.type === "audio" ? null : [`review/event/${search.id}`], ); + const handleDebugReplay = useCallback(() => { + setIsStarting(true); + + axios + .post("debug_replay/start", { + camera: search.camera, + start_time: search.start_time, + end_time: search.end_time, + }) + .then((response) => { + if (response.status === 202 || response.status === 200) { + navigate("/replay"); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + + if (error.response?.status === 409) { + toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), { + position: "top-center", + closeButton: true, + dismissible: false, + action: ( + + + + ), + }); + } else { + toast.error(t("dialog.toast.error", { error: errorMessage }), { + position: "top-center", + }); + } + }) + .finally(() => { + setIsStarting(false); + }); + }, [navigate, search.camera, search.start_time, search.end_time, t]); + // don't render menu at all if no options are available const hasSemanticSearchOption = config?.semantic_search.enabled && @@ -172,6 +228,24 @@ export default function DetailActionsMenu({
)} + + {search.has_clip && ( + { + setIsOpen(false); + handleDebugReplay(); + }} + > + + {isStarting + ? t("dialog.starting", { ns: "views/replay" }) + : t("itemMenu.debugReplay.label")} + + + )} diff --git a/web/src/hooks/use-config-override.ts b/web/src/hooks/use-config-override.ts index 689e99f4b1..90ce717293 100644 --- a/web/src/hooks/use-config-override.ts +++ b/web/src/hooks/use-config-override.ts @@ -59,6 +59,64 @@ function stripHiddenPaths(value: JsonValue, hiddenFields: string[]): JsonValue { return cloned; } +/** + * Field paths that the backend resolves per-camera at runtime (from `fps`, + * stream introspection, or other camera-local state) but defaults to `None` + * in the global Pydantic model. Because the `/config` endpoint serializes + * with `exclude_none=True`, these paths are absent from the global section + * yet always populated on cameras, which would otherwise make every camera + * appear to override fields the user never set globally. + */ +const AUTO_DERIVED_FIELDS: Record = { + detect: [ + "width", + "height", + "min_initialized", + "max_disappeared", + "stationary.interval", + "stationary.threshold", + ], +}; + +/** + * Drop auto-derived field paths from the camera value when the global value + * has no explicit setting for that path. If the user later sets one of these + * fields globally, the path will be present in `globalValue` and normal + * comparison resumes. + */ +function stripAutoDerivedMissingFromGlobal( + sectionPath: string, + globalValue: JsonValue, + cameraValue: JsonValue, +): JsonValue { + const fields = AUTO_DERIVED_FIELDS[sectionPath]; + if (!fields || !isJsonObject(cameraValue)) return cameraValue; + const cloned = cloneDeep(cameraValue) as JsonObject; + for (const path of fields) { + if (get(globalValue, path) === undefined) { + unsetWithWildcard(cloned as Record, path); + } + } + return cloned; +} + +/** + * Whether the given field is auto-derived for `sectionPath` and the global + * value at that path is missing — in which case a per-camera value should + * not be treated as an override. + */ +function isAutoDerivedMissingFromGlobal( + sectionPath: string, + fieldPath: string, + globalValue: unknown, +): boolean { + const fields = AUTO_DERIVED_FIELDS[sectionPath]; + if (!fields) return false; + if (!fields.includes(fieldPath)) return false; + const value = get(globalValue as JsonObject, fieldPath); + return value === undefined || value === null; +} + /** * Collapse null and empty-object values for override comparisons so * semantically equivalent shapes match. The schema may default `mask: None` @@ -234,10 +292,15 @@ export function useConfigOverride({ collapseEmpty(normalizedGlobalValue), hiddenFields, ); - const collapsedCamera = stripHiddenPaths( + const collapsedCameraRaw = stripHiddenPaths( collapseEmpty(normalizedCameraValue), hiddenFields, ); + const collapsedCamera = stripAutoDerivedMissingFromGlobal( + sectionPath, + collapsedGlobal, + collapsedCameraRaw, + ); const comparisonGlobal = compareFields ? pickFields(collapsedGlobal, compareFields) @@ -258,6 +321,20 @@ export function useConfigOverride({ const globalFieldValue = get(normalizedGlobalValue, fieldPath); const cameraFieldValue = get(normalizedCameraValue, fieldPath); + if ( + isAutoDerivedMissingFromGlobal( + sectionPath, + fieldPath, + normalizedGlobalValue, + ) + ) { + return { + isOverridden: false, + globalValue: globalFieldValue, + cameraValue: cameraFieldValue, + }; + } + return { isOverridden: !isEqual( collapseEmpty(globalFieldValue as JsonValue), @@ -367,10 +444,15 @@ export function useAllCameraOverrides( collapseEmpty(globalValue), hiddenFields, ); - const collapsedCamera = stripHiddenPaths( + const collapsedCameraRaw = stripHiddenPaths( collapseEmpty(cameraValue), hiddenFields, ); + const collapsedCamera = stripAutoDerivedMissingFromGlobal( + key, + collapsedGlobal, + collapsedCameraRaw, + ); const comparisonGlobal = compareFields ? pickFields(collapsedGlobal, compareFields) : collapsedGlobal; @@ -615,7 +697,11 @@ export function useCamerasOverridingSection( const deltasByPath = new Map(); // 1. Camera-level overrides (uses base_config when a profile is active) - const cameraValue = collapseEmpty(cameraSectionValues[idx]); + const cameraValue = stripAutoDerivedMissingFromGlobal( + sectionPath, + globalValue, + collapseEmpty(cameraSectionValues[idx]), + ); for (const delta of collectFieldDeltas( globalValue, cameraValue, @@ -696,9 +782,13 @@ export function useCameraSectionDeltas( const globalValue = collapseEmpty( getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema), ); - const cameraValue = collapseEmpty( - normalizeConfigValue( - getBaseCameraSectionValue(config, cameraName, sectionPath), + const cameraValue = stripAutoDerivedMissingFromGlobal( + sectionPath, + globalValue, + collapseEmpty( + normalizeConfigValue( + getBaseCameraSectionValue(config, cameraName, sectionPath), + ), ), ); diff --git a/web/src/hooks/use-stats.ts b/web/src/hooks/use-stats.ts index 77dd9fc019..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); @@ -102,6 +106,11 @@ export default function useStats(stats: FrigateStats | undefined) { // check camera cpu usages Object.entries(memoizedStats["cameras"]).forEach(([name, cam]) => { + // Skip replay cameras + if (isReplayCamera(name)) { + return; + } + const ffmpegAvg = parseFloat( memoizedStats["cpu_usages"][cam["ffmpeg_pid"]]?.cpu_average, ); @@ -111,12 +120,7 @@ export default function useStats(stats: FrigateStats | undefined) { const cameraName = config?.cameras?.[name]?.friendly_name ?? name; - // Skip ffmpeg warnings for replay cameras - if ( - !isNaN(ffmpegAvg) && - ffmpegAvg >= CameraFfmpegThreshold.error && - !isReplayCamera(name) - ) { + if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) { problems.push({ text: t("stats.ffmpegHighCpuUsage", { camera: capitalizeFirstLetter(capitalizeAll(cameraName)), @@ -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 }; } diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 7c862d6ad1..6ebfa92638 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -106,6 +106,12 @@ import SaveAllPreviewPopover, { type SaveAllPreviewItem, } from "@/components/overlay/detail/SaveAllPreviewPopover"; import { useRestart } from "@/api/ws"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { TooltipPortal } from "@radix-ui/react-tooltip"; const allSettingsViews = [ "uiSettings", @@ -1505,10 +1511,20 @@ export default function Settings() { CAMERA_SECTION_KEYS.has(key) && status?.isOverridden; const showUnsavedDot = status?.hasChanges; - const dotColor = - status?.overrideSource === "profile" && activeProfileColor - ? activeProfileColor.dot - : "bg-selected"; + const isProfileOverride = + status?.overrideSource === "profile" && activeProfileColor; + const dotColor = isProfileOverride + ? activeProfileColor.dot + : "bg-selected"; + + const overrideTooltip = isProfileOverride + ? t("menuDot.overrideProfile", { + profile: activeEditingProfile + ? (profileFriendlyNames.get(activeEditingProfile) ?? + activeEditingProfile) + : "", + }) + : t("menuDot.overrideGlobal"); return (
@@ -1518,19 +1534,46 @@ export default function Settings() { {(showOverrideDot || showUnsavedDot) && (
{showOverrideDot && ( - + + + + + + + {overrideTooltip} + + + )} {showUnsavedDot && ( - + + + + + + + {t("menuDot.unsaved")} + + + )}
)}
); }, - [sectionStatusByKey, t, activeProfileColor], + [ + sectionStatusByKey, + t, + activeProfileColor, + activeEditingProfile, + profileFriendlyNames, + ], ); if (isMobile) { diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index 6233b232c6..83f2111c6c 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -1,5 +1,5 @@ import Heading from "@/components/ui/heading"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CONTROL_COLUMN_CLASS_NAME, SettingsGroupCard, @@ -14,7 +14,15 @@ import { useTranslation } from "react-i18next"; import CameraEditForm from "@/components/settings/CameraEditForm"; import CameraWizardDialog from "@/components/settings/CameraWizardDialog"; import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog"; -import { LuExternalLink, LuPencil, LuPlus, LuTrash2 } from "react-icons/lu"; +import { + LuCheck, + LuExternalLink, + LuGripVertical, + LuPencil, + LuPlus, + LuTrash2, +} from "react-icons/lu"; +import { Reorder, useDragControls } from "framer-motion"; import { IoMdArrowRoundBack } from "react-icons/io"; import { Link } from "react-router-dom"; import { useDocDomain } from "@/hooks/use-doc-domain"; @@ -45,6 +53,10 @@ import { SelectValue, } from "@/components/ui/select"; +const REORDER_SAVED_INDICATOR_MS = 1500; + +type ReorderSaveStatus = "idle" | "saving" | "saved"; + type CameraManagementViewProps = { setUnsavedChanges: React.Dispatch>; profileState?: ProfileState; @@ -54,7 +66,7 @@ export default function CameraManagementView({ setUnsavedChanges, profileState, }: CameraManagementViewProps) { - const { t } = useTranslation(["views/settings"]); + const { t } = useTranslation(["views/settings", "common"]); const { data: config, mutate: updateConfig } = useSWR("config"); @@ -72,16 +84,99 @@ export default function CameraManagementView({ const [restartDialogOpen, setRestartDialogOpen] = useState(false); const { send: sendRestart } = useRestart(); - // List of cameras for dropdown const enabledCameras = useMemo(() => { if (config) { return Object.keys(config.cameras) .filter((camera) => config.cameras[camera].enabled_in_config) - .sort(); + .sort((a, b) => { + const orderA = config.cameras[a].ui?.order ?? 0; + const orderB = config.cameras[b].ui?.order ?? 0; + if (orderA !== orderB) return orderA - orderB; + return a.localeCompare(b); + }); } return []; }, [config]); + // Diverges from config during a drag and while the save is in flight. + const [orderedCameras, setOrderedCameras] = + useState(enabledCameras); + const orderedCamerasRef = useRef(orderedCameras); + useEffect(() => { + orderedCamerasRef.current = orderedCameras; + }, [orderedCameras]); + + useEffect(() => { + setOrderedCameras((prev) => { + if ( + prev.length === enabledCameras.length && + prev.every((cam, i) => cam === enabledCameras[i]) + ) { + return prev; + } + return enabledCameras; + }); + }, [enabledCameras]); + + const [reorderSaveStatus, setReorderSaveStatus] = + useState("idle"); + const reorderSavedTimerRef = useRef | null>( + null, + ); + useEffect(() => { + return () => { + if (reorderSavedTimerRef.current) { + clearTimeout(reorderSavedTimerRef.current); + } + }; + }, []); + + const handleReorderDragEnd = useCallback(async () => { + const current = orderedCamerasRef.current; + if ( + current.length === enabledCameras.length && + current.every((cam, i) => cam === enabledCameras[i]) + ) { + return; + } + + const cameraUpdates: Record = {}; + current.forEach((cam, i) => { + cameraUpdates[cam] = { ui: { order: i * 10 } }; + }); + + if (reorderSavedTimerRef.current) { + clearTimeout(reorderSavedTimerRef.current); + reorderSavedTimerRef.current = null; + } + setReorderSaveStatus("saving"); + + try { + await axios.put("config/set", { + requires_restart: 0, + config_data: { cameras: cameraUpdates }, + }); + await updateConfig(); + setReorderSaveStatus("saved"); + reorderSavedTimerRef.current = setTimeout(() => { + setReorderSaveStatus("idle"); + reorderSavedTimerRef.current = null; + }, REORDER_SAVED_INDICATOR_MS); + } catch (error) { + setOrderedCameras(enabledCameras); + setReorderSaveStatus("idle"); + const errorMessage = + axios.isAxiosError(error) && + (error.response?.data?.message || error.response?.data?.detail) + ? error.response?.data?.message || error.response?.data?.detail + : t("toast.save.error.noMessage", { ns: "common" }); + + toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), { + position: "top-center", + }); + } + }, [enabledCameras, updateConfig, t]); + const disabledCameras = useMemo(() => { if (config) { return Object.keys(config.cameras) @@ -173,22 +268,26 @@ export default function CameraManagementView({

-
- {enabledCameras.map((camera) => ( -
-
- - -
- -
- ))} +
+ + {orderedCameras.map((camera) => ( + + ))} + +

@@ -309,6 +408,80 @@ export default function CameraManagementView({ ); } +type ReorderSaveStatusIndicatorProps = { + status: ReorderSaveStatus; +}; + +function ReorderSaveStatusIndicator({ + status, +}: ReorderSaveStatusIndicatorProps) { + const { t } = useTranslation(["views/settings"]); + return ( +

+ {status === "saving" && ( + + {t("cameraManagement.streams.saving")} + + )} + {status === "saved" && ( + + + {t("cameraManagement.streams.saved")} + + )} +
+ ); +} + +type EnabledCameraRowProps = { + camera: string; + onConfigChanged: () => Promise; + onDragEnd: () => void; +}; + +function EnabledCameraRow({ + camera, + onConfigChanged, + onDragEnd, +}: EnabledCameraRowProps) { + const { t } = useTranslation(["views/settings"]); + const controls = useDragControls(); + + return ( + +
+ + + +
+ +
+ ); +} + type CameraEnableSwitchProps = { cameraName: string; };