Debug replay (#22212)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* debug replay implementation

* fix masks after dev rebase

* fix squash merge issues

* fix

* fix

* fix

* no need to write debug replay camera to config

* camera and filter button and dropdown

* add filters

* add ability to edit motion and object config for debug replay

* add debug draw overlay to debug replay

* add guard to prevent crash when camera is no longer in camera_states

* fix overflow due to radix absolutely positioned elements

* increase number of messages

* ensure deep_merge replaces existing list values when override is true

* add back button

* add debug replay to explore and review menus

* clean up

* clean up

* update instructions to prevent exposing exception info

* fix typing

* refactor output logic

* refactor with helper function

* move init to function for consistency
This commit is contained in:
Josh Hawkins 2026-03-04 10:07:34 -06:00 committed by GitHub
parent 5e7d426768
commit 95956a690b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 4572 additions and 519 deletions

View File

@ -324,6 +324,12 @@ try:
value = await sensor.read()
except Exception: # ❌ Too broad
logger.error("Failed")
# Returning exceptions in JSON responses
except ValueError as e:
return JSONResponse(
content={"success": False, "message": str(e)},
)
```
### ✅ Use These Instead
@ -353,6 +359,16 @@ try:
value = await sensor.read()
except SensorException as err: # ✅ Specific
logger.exception("Failed to read sensor")
# Safe error responses
except ValueError:
logger.exception("Invalid parameters for API request")
return JSONResponse(
content={
"success": False,
"message": "Invalid request parameters",
},
)
```
## Project-Specific Conventions

View File

@ -75,4 +75,4 @@ Many providers also have a public facing chat interface for their models. Downlo
- OpenAI - [ChatGPT](https://chatgpt.com)
- Gemini - [Google AI Studio](https://aistudio.google.com)
- Ollama - [Open WebUI](https://docs.openwebui.com/)
- Ollama - [Open WebUI](https://docs.openwebui.com/)

View File

@ -49,12 +49,13 @@ from frigate.stats.prometheus import get_metrics, update_metrics
from frigate.types import JobStatusTypesEnum
from frigate.util.builtin import (
clean_camera_user_pass,
deep_merge,
flatten_config_data,
load_labels,
process_config_query_string,
update_yaml_file_bulk,
)
from frigate.util.config import find_config_file
from frigate.util.config import apply_section_update, find_config_file
from frigate.util.schema import get_config_schema
from frigate.util.services import (
get_nvidia_driver_info,
@ -422,9 +423,100 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
)
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
"""Apply config changes in-memory only, without writing to YAML.
Used for temporary config changes like debug replay camera tuning.
Updates the in-memory Pydantic config and publishes ZMQ updates,
bypassing YAML parsing entirely.
"""
try:
updates = {}
if body.config_data:
updates = flatten_config_data(body.config_data)
updates = {k: ("" if v is None else v) for k, v in updates.items()}
if not updates:
return JSONResponse(
content={"success": False, "message": "No configuration data provided"},
status_code=400,
)
config: FrigateConfig = request.app.frigate_config
# Group flat key paths into nested per-camera, per-section dicts
grouped: dict[str, dict[str, dict]] = {}
for key_path, value in updates.items():
parts = key_path.split(".")
if len(parts) < 3 or parts[0] != "cameras":
continue
cam, section = parts[1], parts[2]
grouped.setdefault(cam, {}).setdefault(section, {})
# Build nested dict from remaining path (e.g. "filters.person.threshold")
target = grouped[cam][section]
for part in parts[3:-1]:
target = target.setdefault(part, {})
if len(parts) > 3:
target[parts[-1]] = value
elif isinstance(value, dict):
grouped[cam][section] = deep_merge(
grouped[cam][section], value, override=True
)
else:
grouped[cam][section] = value
# Apply each section update
for cam_name, sections in grouped.items():
camera_config = config.cameras.get(cam_name)
if not camera_config:
return JSONResponse(
content={
"success": False,
"message": f"Camera '{cam_name}' not found",
},
status_code=400,
)
for section_name, update in sections.items():
err = apply_section_update(camera_config, section_name, update)
if err is not None:
return JSONResponse(
content={"success": False, "message": err},
status_code=400,
)
# Publish ZMQ updates so processing threads pick up changes
if body.update_topic and body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/")
settings = getattr(config.cameras.get(camera, None), field, None)
if settings is not None:
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera),
settings,
)
return JSONResponse(
content={"success": True, "message": "Config applied in-memory"},
status_code=200,
)
except Exception as e:
logger.error(f"Error applying config in-memory: {e}")
return JSONResponse(
content={"success": False, "message": "Error applying config"},
status_code=500,
)
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
def config_set(request: Request, body: AppConfigSetBody):
config_file = find_config_file()
if body.skip_save:
return _config_set_in_memory(request, body)
lock = FileLock(f"{config_file}.lock", timeout=5)
try:

176
frigate/api/debug_replay.py Normal file
View File

@ -0,0 +1,176 @@
"""Debug replay API endpoints."""
import asyncio
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.app])
class DebugReplayStartBody(BaseModel):
"""Request body for starting a debug replay session."""
camera: str = Field(title="Source camera name")
start_time: float = Field(title="Start timestamp")
end_time: float = Field(title="End timestamp")
class DebugReplayStartResponse(BaseModel):
"""Response for starting a debug replay session."""
success: bool
replay_camera: str
class DebugReplayStatusResponse(BaseModel):
"""Response for debug replay status."""
active: bool
replay_camera: str | None = None
source_camera: str | None = None
start_time: float | None = None
end_time: float | None = None
live_ready: bool = False
class DebugReplayStopResponse(BaseModel):
"""Response for stopping a debug replay session."""
success: bool
@router.post(
"/debug_replay/start",
response_model=DebugReplayStartResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Start debug replay",
description="Start a debug replay session from camera recordings.",
)
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
"""Start a debug replay session."""
replay_manager = request.app.replay_manager
if replay_manager.active:
return JSONResponse(
content={
"success": False,
"message": "A replay session is already active",
},
status_code=409,
)
try:
replay_camera = await asyncio.to_thread(
replay_manager.start,
source_camera=body.camera,
start_ts=body.start_time,
end_ts=body.end_time,
frigate_config=request.app.frigate_config,
config_publisher=request.app.config_publisher,
)
except ValueError:
logger.exception("Invalid parameters for debug replay start request")
return JSONResponse(
content={
"success": False,
"message": "Invalid debug replay request parameters",
},
status_code=400,
)
except RuntimeError:
logger.exception("Error while starting debug replay session")
return JSONResponse(
content={
"success": False,
"message": "An internal error occurred while starting debug replay",
},
status_code=500,
)
return DebugReplayStartResponse(
success=True,
replay_camera=replay_camera,
)
@router.get(
"/debug_replay/status",
response_model=DebugReplayStatusResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Get debug replay status",
description="Get the status of the current debug replay session.",
)
def get_debug_replay_status(request: Request):
"""Get the current replay session status."""
replay_manager = request.app.replay_manager
live_ready = False
replay_camera = replay_manager.replay_camera_name
if replay_manager.active and replay_camera:
frame_processor = request.app.detected_frames_processor
frame = frame_processor.get_current_frame(replay_camera)
if frame is not None:
frame_time = frame_processor.get_current_frame_time(replay_camera)
camera_config = request.app.frigate_config.cameras.get(replay_camera)
retry_interval = 10
if camera_config is not None:
retry_interval = float(camera_config.ffmpeg.retry_interval or 10)
live_ready = datetime.now().timestamp() <= frame_time + retry_interval
return DebugReplayStatusResponse(
active=replay_manager.active,
replay_camera=replay_camera,
source_camera=replay_manager.source_camera,
start_time=replay_manager.start_ts,
end_time=replay_manager.end_ts,
live_ready=live_ready,
)
@router.post(
"/debug_replay/stop",
response_model=DebugReplayStopResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Stop debug replay",
description="Stop the active debug replay session and clean up all artifacts.",
)
async def stop_debug_replay(request: Request):
"""Stop the active replay session."""
replay_manager = request.app.replay_manager
if not replay_manager.active:
return JSONResponse(
content={"success": False, "message": "No active replay session"},
status_code=400,
)
try:
await asyncio.to_thread(
replay_manager.stop,
frigate_config=request.app.frigate_config,
config_publisher=request.app.config_publisher,
)
except (ValueError, RuntimeError, OSError) as e:
logger.error("Error stopping replay: %s", e)
return JSONResponse(
content={
"success": False,
"message": "Failed to stop replay session due to an internal error.",
},
status_code=500,
)
return DebugReplayStopResponse(success=True)

View File

@ -7,6 +7,7 @@ class AppConfigSetBody(BaseModel):
requires_restart: int = 1
update_topic: str | None = None
config_data: Optional[Dict[str, Any]] = None
skip_save: bool = False
class AppPutPasswordBody(BaseModel):

View File

@ -18,6 +18,7 @@ from frigate.api import (
camera,
chat,
classification,
debug_replay,
event,
export,
media,
@ -32,6 +33,7 @@ from frigate.comms.event_metadata_updater import (
)
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.debug_replay import DebugReplayManager
from frigate.embeddings import EmbeddingsContext
from frigate.genai import GenAIClientManager
from frigate.ptz.onvif import OnvifController
@ -65,6 +67,7 @@ def create_fastapi_app(
stats_emitter: StatsEmitter,
event_metadata_updater: EventMetadataPublisher,
config_publisher: CameraConfigUpdatePublisher,
replay_manager: DebugReplayManager,
enforce_default_admin: bool = True,
):
logger.info("Starting FastAPI app")
@ -133,6 +136,7 @@ def create_fastapi_app(
app.include_router(event.router)
app.include_router(media.router)
app.include_router(record.router)
app.include_router(debug_replay.router)
# App Properties
app.frigate_config = frigate_config
app.genai_manager = GenAIClientManager(frigate_config)
@ -144,6 +148,7 @@ def create_fastapi_app(
app.stats_emitter = stats_emitter
app.event_metadata_updater = event_metadata_updater
app.config_publisher = config_publisher
app.replay_manager = replay_manager
if frigate_config.auth.enabled:
secret = get_jwt_secret()

View File

@ -43,6 +43,10 @@ from frigate.const import (
)
from frigate.data_processing.types import DataProcessorMetrics
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.debug_replay import (
DebugReplayManager,
cleanup_replay_cameras,
)
from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
from frigate.events.audio import AudioProcessor
from frigate.events.cleanup import EventCleanup
@ -139,6 +143,9 @@ class FrigateApp:
else:
logger.debug(f"Skipping directory: {d}")
def init_debug_replay_manager(self) -> None:
self.replay_manager = DebugReplayManager()
def init_camera_metrics(self) -> None:
# create camera_metrics
for camera_name in self.config.cameras.keys():
@ -531,6 +538,7 @@ class FrigateApp:
set_file_limit()
# Start frigate services.
self.init_debug_replay_manager()
self.init_camera_metrics()
self.init_queues()
self.init_database()
@ -541,6 +549,10 @@ class FrigateApp:
self.init_embeddings_manager()
self.bind_database()
self.check_db_data_migrations()
# Clean up any stale replay camera artifacts (filesystem + DB)
cleanup_replay_cameras()
self.init_inter_process_communicator()
self.start_detectors()
self.init_dispatcher()
@ -572,6 +584,7 @@ class FrigateApp:
self.stats_emitter,
self.event_metadata_updater,
self.inter_config_updater,
self.replay_manager,
),
host="127.0.0.1",
port=5001,
@ -637,6 +650,7 @@ class FrigateApp:
self.record_cleanup.join()
self.stats_emitter.join()
self.frigate_watchdog.join()
self.camera_maintainer.join()
self.db.stop()
# Save embeddings stats to disk

View File

@ -57,6 +57,9 @@ class CameraActivityManager:
all_objects: list[dict[str, Any]] = []
for camera in new_activity.keys():
if camera not in self.config.cameras:
continue
# handle cameras that were added dynamically
if camera not in self.camera_all_object_counts:
self.__init_camera(self.config.cameras[camera])
@ -124,7 +127,11 @@ class CameraActivityManager:
any_changed = False
# run through each object and check what topics need to be updated
for label in self.config.cameras[camera].objects.track:
camera_config = self.config.cameras.get(camera)
if camera_config is None:
return
for label in camera_config.objects.track:
if label in self.config.model.non_logo_attributes:
continue
@ -174,6 +181,9 @@ class AudioActivityManager:
now = datetime.datetime.now().timestamp()
for camera in new_activity.keys():
if camera not in self.config.cameras:
continue
# handle cameras that were added dynamically
if camera not in self.current_audio_detections:
self.__init_camera(self.config.cameras[camera])
@ -193,7 +203,11 @@ class AudioActivityManager:
def compare_audio_activity(
self, camera: str, new_detections: list[tuple[str, float]], now: float
) -> None:
max_not_heard = self.config.cameras[camera].audio.max_not_heard
camera_config = self.config.cameras.get(camera)
if camera_config is None:
return False
max_not_heard = camera_config.audio.max_not_heard
current = self.current_audio_detections[camera]
any_changed = False

View File

@ -55,8 +55,20 @@ class CameraMaintainer(threading.Thread):
self.shm_count = self.__calculate_shm_frame_count()
self.camera_processes: dict[str, mp.Process] = {}
self.capture_processes: dict[str, mp.Process] = {}
self.camera_stop_events: dict[str, MpEvent] = {}
self.metrics_manager = metrics_manager
def __ensure_camera_stop_event(self, camera: str) -> MpEvent:
camera_stop_event = self.camera_stop_events.get(camera)
if camera_stop_event is None:
camera_stop_event = mp.Event()
self.camera_stop_events[camera] = camera_stop_event
else:
camera_stop_event.clear()
return camera_stop_event
def __init_historical_regions(self) -> None:
# delete region grids for removed or renamed cameras
cameras = list(self.config.cameras.keys())
@ -99,6 +111,8 @@ class CameraMaintainer(threading.Thread):
logger.info(f"Camera processor not started for disabled camera {name}")
return
camera_stop_event = self.__ensure_camera_stop_event(name)
if runtime:
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
self.ptz_metrics[name] = PTZMetrics(autotracker_enabled=False)
@ -135,7 +149,7 @@ class CameraMaintainer(threading.Thread):
self.camera_metrics[name],
self.ptz_metrics[name],
self.region_grids[name],
self.stop_event,
camera_stop_event,
self.config.logger,
)
self.camera_processes[config.name] = camera_process
@ -150,6 +164,8 @@ class CameraMaintainer(threading.Thread):
logger.info(f"Capture process not started for disabled camera {name}")
return
camera_stop_event = self.__ensure_camera_stop_event(name)
# pre-create shms
count = 10 if runtime else self.shm_count
for i in range(count):
@ -160,7 +176,7 @@ class CameraMaintainer(threading.Thread):
config,
count,
self.camera_metrics[name],
self.stop_event,
camera_stop_event,
self.config.logger,
)
capture_process.daemon = True
@ -170,18 +186,36 @@ class CameraMaintainer(threading.Thread):
logger.info(f"Capture process started for {name}: {capture_process.pid}")
def __stop_camera_capture_process(self, camera: str) -> None:
capture_process = self.capture_processes[camera]
capture_process = self.capture_processes.get(camera)
if capture_process is not None:
logger.info(f"Waiting for capture process for {camera} to stop")
capture_process.terminate()
capture_process.join()
camera_stop_event = self.camera_stop_events.get(camera)
if camera_stop_event is not None:
camera_stop_event.set()
capture_process.join(timeout=10)
if capture_process.is_alive():
logger.warning(
f"Capture process for {camera} didn't exit, forcing termination"
)
capture_process.terminate()
capture_process.join()
def __stop_camera_process(self, camera: str) -> None:
camera_process = self.camera_processes[camera]
camera_process = self.camera_processes.get(camera)
if camera_process is not None:
logger.info(f"Waiting for process for {camera} to stop")
camera_process.terminate()
camera_process.join()
camera_stop_event = self.camera_stop_events.get(camera)
if camera_stop_event is not None:
camera_stop_event.set()
camera_process.join(timeout=10)
if camera_process.is_alive():
logger.warning(f"Process for {camera} didn't exit, forcing termination")
camera_process.terminate()
camera_process.join()
logger.info(f"Closing frame queue for {camera}")
empty_and_close_queue(self.camera_metrics[camera].frame_queue)
@ -199,6 +233,12 @@ class CameraMaintainer(threading.Thread):
for update_type, updated_cameras in updates.items():
if update_type == CameraConfigUpdateEnum.add.name:
for camera in updated_cameras:
if (
camera in self.camera_processes
or camera in self.capture_processes
):
continue
self.__start_camera_processor(
camera,
self.update_subscriber.camera_configs[camera],
@ -210,15 +250,22 @@ class CameraMaintainer(threading.Thread):
runtime=True,
)
elif update_type == CameraConfigUpdateEnum.remove.name:
self.__stop_camera_capture_process(camera)
self.__stop_camera_process(camera)
for camera in updated_cameras:
self.__stop_camera_capture_process(camera)
self.__stop_camera_process(camera)
self.capture_processes.pop(camera, None)
self.camera_processes.pop(camera, None)
self.camera_stop_events.pop(camera, None)
self.region_grids.pop(camera, None)
self.camera_metrics.pop(camera, None)
self.ptz_metrics.pop(camera, None)
# ensure the capture processes are done
for camera in self.camera_processes.keys():
for camera in self.capture_processes.keys():
self.__stop_camera_capture_process(camera)
# ensure the camera processors are done
for camera in self.capture_processes.keys():
for camera in self.camera_processes.keys():
self.__stop_camera_process(camera)
self.update_subscriber.stop()

View File

@ -26,8 +26,8 @@ class ConfigPublisher:
def stop(self) -> None:
self.stop_event.set()
self.socket.close()
self.context.destroy()
self.socket.close(linger=0)
self.context.destroy(linger=0)
class ConfigSubscriber:
@ -55,5 +55,5 @@ class ConfigSubscriber:
return (None, None)
def stop(self) -> None:
self.socket.close()
self.context.destroy()
self.socket.close(linger=0)
self.context.destroy(linger=0)

View File

@ -110,6 +110,9 @@ class Dispatcher:
payload: str,
sub_command: str | None = None,
) -> None:
if camera_name not in self.config.cameras:
return
try:
if command_type == "set":
if sub_command:
@ -131,6 +134,9 @@ class Dispatcher:
def handle_request_region_grid() -> Any:
camera = payload
if camera not in self.config.cameras:
return None
grid = get_camera_regions_grid(
camera,
self.config.cameras[camera].detect,
@ -243,7 +249,11 @@ class Dispatcher:
self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()))
def handle_on_connect() -> None:
camera_status = self.camera_activity.last_camera_activity.copy()
camera_status = {
camera: status
for camera, status in self.camera_activity.last_camera_activity.copy().items()
if camera in self.config.cameras
}
audio_detections = self.audio_activity.current_audio_detections.copy()
cameras_with_status = camera_status.keys()
@ -346,7 +356,8 @@ class Dispatcher:
# example /cam_name/notifications/suspend payload=duration
camera_name = parts[-3]
command = parts[-2]
self._on_camera_notification_suspend(camera_name, payload)
if camera_name in self.config.cameras:
self._on_camera_notification_suspend(camera_name, payload)
except IndexError:
logger.error(
f"Received invalid {topic.split('/')[-1]} command: {topic}"

View File

@ -61,8 +61,8 @@ class InterProcessCommunicator(Communicator):
def stop(self) -> None:
self.stop_event.set()
self.reader_thread.join()
self.socket.close()
self.context.destroy()
self.socket.close(linger=0)
self.context.destroy(linger=0)
class InterProcessRequestor:
@ -82,5 +82,5 @@ class InterProcessRequestor:
return ""
def stop(self) -> None:
self.socket.close()
self.context.destroy()
self.socket.close(linger=0)
self.context.destroy(linger=0)

View File

@ -43,7 +43,7 @@ class ZmqProxy:
def stop(self) -> None:
# destroying the context will tell the proxy to stop
self.context.destroy()
self.context.destroy(linger=0)
self.runner.join()
@ -66,8 +66,8 @@ class Publisher(Generic[T]):
self.socket.send_string(f"{self.topic}{sub_topic} {json.dumps(payload)}")
def stop(self) -> None:
self.socket.close()
self.context.destroy()
self.socket.close(linger=0)
self.context.destroy(linger=0)
class Subscriber(Generic[T]):
@ -96,8 +96,8 @@ class Subscriber(Generic[T]):
return self._return_object("", None)
def stop(self) -> None:
self.socket.close()
self.context.destroy()
self.socket.close(linger=0)
self.context.destroy(linger=0)
def _return_object(self, topic: str, payload: T | None) -> T | None:
return payload

View File

@ -80,8 +80,8 @@ class CameraConfigUpdateSubscriber:
self.camera_configs[camera] = updated_config
return
elif update_type == CameraConfigUpdateEnum.remove:
self.config.cameras.pop(camera)
self.camera_configs.pop(camera)
self.config.cameras.pop(camera, None)
self.camera_configs.pop(camera, None)
return
config = self.camera_configs.get(camera)

View File

@ -14,6 +14,8 @@ RECORD_DIR = f"{BASE_DIR}/recordings"
TRIGGER_DIR = f"{CLIPS_DIR}/triggers"
BIRDSEYE_PIPE = "/tmp/cache/birdseye"
CACHE_DIR = "/tmp/cache"
REPLAY_CAMERA_PREFIX = "_replay_"
REPLAY_DIR = os.path.join(CACHE_DIR, "replay")
PLUS_ENV_VAR = "PLUS_API_KEY"
PLUS_API_HOST = "https://api.frigate.video"

443
frigate/debug_replay.py Normal file
View File

@ -0,0 +1,443 @@
"""Debug replay camera management for replaying recordings with detection overlays."""
import logging
import os
import shutil
import subprocess as sp
import threading
from ruamel.yaml import YAML
from frigate.config import FrigateConfig
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdatePublisher,
CameraConfigUpdateTopic,
)
from frigate.const import (
CLIPS_DIR,
RECORD_DIR,
REPLAY_CAMERA_PREFIX,
REPLAY_DIR,
THUMB_DIR,
)
from frigate.models import Event, Recordings, ReviewSegment, Timeline
from frigate.util.config import find_config_file
logger = logging.getLogger(__name__)
class DebugReplayManager:
"""Manages a single debug replay session."""
def __init__(self) -> None:
self._lock = threading.Lock()
self.replay_camera_name: str | None = None
self.source_camera: str | None = None
self.clip_path: str | None = None
self.start_ts: float | None = None
self.end_ts: float | None = None
@property
def active(self) -> bool:
"""Whether a replay session is currently active."""
return self.replay_camera_name is not None
def start(
self,
source_camera: str,
start_ts: float,
end_ts: float,
frigate_config: FrigateConfig,
config_publisher: CameraConfigUpdatePublisher,
) -> str:
"""Start a debug replay session.
Args:
source_camera: Name of the source camera to replay
start_ts: Start timestamp
end_ts: End timestamp
frigate_config: Current Frigate configuration
config_publisher: Publisher for camera config updates
Returns:
The replay camera name
Raises:
ValueError: If a session is already active or parameters are invalid
RuntimeError: If clip generation fails
"""
with self._lock:
return self._start_locked(
source_camera, start_ts, end_ts, frigate_config, config_publisher
)
def _start_locked(
self,
source_camera: str,
start_ts: float,
end_ts: float,
frigate_config: FrigateConfig,
config_publisher: CameraConfigUpdatePublisher,
) -> str:
if self.active:
raise ValueError("A replay session is already active")
if source_camera not in frigate_config.cameras:
raise ValueError(f"Camera '{source_camera}' not found")
if end_ts <= start_ts:
raise ValueError("End time must be after start time")
# Query recordings for the source camera in the time range
recordings = (
Recordings.select(
Recordings.path,
Recordings.start_time,
Recordings.end_time,
)
.where(
Recordings.start_time.between(start_ts, end_ts)
| Recordings.end_time.between(start_ts, end_ts)
| ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time))
)
.where(Recordings.camera == source_camera)
.order_by(Recordings.start_time.asc())
)
if not recordings.count():
raise ValueError(
f"No recordings found for camera '{source_camera}' in the specified time range"
)
# Create replay directory
os.makedirs(REPLAY_DIR, exist_ok=True)
# Generate replay camera name
replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}"
# Build concat file for ffmpeg
concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt")
clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4")
with open(concat_file, "w") as f:
for recording in recordings:
f.write(f"file '{recording.path}'\n")
# Concatenate recordings into a single clip with -c copy (fast)
ffmpeg_cmd = [
frigate_config.ffmpeg.ffmpeg_path,
"-hide_banner",
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
concat_file,
"-c",
"copy",
"-movflags",
"+faststart",
clip_path,
]
logger.info(
"Generating replay clip for %s (%.1f - %.1f)",
source_camera,
start_ts,
end_ts,
)
try:
result = sp.run(
ffmpeg_cmd,
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
logger.error("FFmpeg error: %s", result.stderr)
raise RuntimeError(
f"Failed to generate replay clip: {result.stderr[-500:]}"
)
except sp.TimeoutExpired:
raise RuntimeError("Clip generation timed out")
finally:
# Clean up concat file
if os.path.exists(concat_file):
os.remove(concat_file)
if not os.path.exists(clip_path):
raise RuntimeError("Clip file was not created")
# Build camera config dict for the replay camera
source_config = frigate_config.cameras[source_camera]
camera_dict = self._build_camera_config_dict(
source_config, replay_name, clip_path
)
# Build an in-memory config with the replay camera added
config_file = find_config_file()
yaml_parser = YAML()
with open(config_file, "r") as f:
config_data = yaml_parser.load(f)
if "cameras" not in config_data or config_data["cameras"] is None:
config_data["cameras"] = {}
config_data["cameras"][replay_name] = camera_dict
try:
new_config = FrigateConfig.parse_object(config_data)
except Exception as e:
raise RuntimeError(f"Failed to validate replay camera config: {e}")
# Update the running config
frigate_config.cameras[replay_name] = new_config.cameras[replay_name]
# Publish the add event
config_publisher.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.add, replay_name),
new_config.cameras[replay_name],
)
# Store session state
self.replay_camera_name = replay_name
self.source_camera = source_camera
self.clip_path = clip_path
self.start_ts = start_ts
self.end_ts = end_ts
logger.info("Debug replay started: %s -> %s", source_camera, replay_name)
return replay_name
def stop(
self,
frigate_config: FrigateConfig,
config_publisher: CameraConfigUpdatePublisher,
) -> None:
"""Stop the active replay session and clean up all artifacts.
Args:
frigate_config: Current Frigate configuration
config_publisher: Publisher for camera config updates
"""
with self._lock:
self._stop_locked(frigate_config, config_publisher)
def _stop_locked(
self,
frigate_config: FrigateConfig,
config_publisher: CameraConfigUpdatePublisher,
) -> None:
if not self.active:
logger.warning("No active replay session to stop")
return
replay_name = self.replay_camera_name
# Publish remove event so subscribers stop and remove from their config
if replay_name in frigate_config.cameras:
config_publisher.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, replay_name),
frigate_config.cameras[replay_name],
)
# Do NOT pop here — let subscribers handle removal from the shared
# config dict when they process the ZMQ message to avoid race conditions
# Defensive DB cleanup
self._cleanup_db(replay_name)
# Remove filesystem artifacts
self._cleanup_files(replay_name)
# Reset state
self.replay_camera_name = None
self.source_camera = None
self.clip_path = None
self.start_ts = None
self.end_ts = None
logger.info("Debug replay stopped and cleaned up: %s", replay_name)
def _build_camera_config_dict(
self,
source_config,
replay_name: str,
clip_path: str,
) -> dict:
"""Build a camera config dictionary for the replay camera.
Args:
source_config: Source camera's CameraConfig
replay_name: Name for the replay camera
clip_path: Path to the replay clip file
Returns:
Camera config as a dictionary
"""
# Extract detect config (exclude computed fields)
detect_dict = source_config.detect.model_dump(
exclude={"min_initialized", "max_disappeared", "enabled_in_config"}
)
# Extract objects config, using .dict() on filters to convert
# RuntimeFilterConfig ndarray masks back to string coordinates
objects_dict = {
"track": source_config.objects.track,
"mask": {
mask_id: (
mask_cfg.model_dump(
exclude={"raw_coordinates", "enabled_in_config"}
)
if mask_cfg is not None
else None
)
for mask_id, mask_cfg in source_config.objects.mask.items()
}
if source_config.objects.mask
else {},
"filters": {
name: filt.dict() if hasattr(filt, "dict") else filt.model_dump()
for name, filt in source_config.objects.filters.items()
},
}
# Extract zones (exclude_defaults avoids serializing empty defaults
# like distances=[] that fail validation on re-parse)
zones_dict = {}
for zone_name, zone_config in source_config.zones.items():
zone_dump = zone_config.model_dump(
exclude={"contour", "color"}, exclude_defaults=True
)
# Always include required fields
zone_dump.setdefault("coordinates", zone_config.coordinates)
zones_dict[zone_name] = zone_dump
# Extract motion config (exclude runtime fields)
motion_dict = {}
if source_config.motion is not None:
motion_dict = source_config.motion.model_dump(
exclude={
"frame_shape",
"raw_mask",
"mask",
"improved_contrast_enabled",
"rasterized_mask",
}
)
return {
"enabled": True,
"ffmpeg": {
"inputs": [
{
"path": clip_path,
"roles": ["detect"],
"input_args": "-re -stream_loop -1 -fflags +genpts",
}
],
"hwaccel_args": [],
},
"detect": detect_dict,
"objects": objects_dict,
"zones": zones_dict,
"motion": motion_dict,
"record": {"enabled": False},
"snapshots": {"enabled": False},
"review": {
"alerts": {"enabled": False},
"detections": {"enabled": False},
},
"birdseye": {"enabled": False},
"audio": {"enabled": False},
"lpr": {"enabled": False},
"face_recognition": {"enabled": False},
}
def _cleanup_db(self, camera_name: str) -> None:
"""Defensively remove any database rows for the replay camera."""
try:
Event.delete().where(Event.camera == camera_name).execute()
except Exception as e:
logger.error("Failed to delete replay events: %s", e)
try:
Timeline.delete().where(Timeline.camera == camera_name).execute()
except Exception as e:
logger.error("Failed to delete replay timeline: %s", e)
try:
Recordings.delete().where(Recordings.camera == camera_name).execute()
except Exception as e:
logger.error("Failed to delete replay recordings: %s", e)
try:
ReviewSegment.delete().where(ReviewSegment.camera == camera_name).execute()
except Exception as e:
logger.error("Failed to delete replay review segments: %s", e)
def _cleanup_files(self, camera_name: str) -> None:
"""Remove filesystem artifacts for the replay camera."""
dirs_to_clean = [
os.path.join(RECORD_DIR, camera_name),
os.path.join(CLIPS_DIR, camera_name),
os.path.join(THUMB_DIR, camera_name),
]
for dir_path in dirs_to_clean:
if os.path.exists(dir_path):
try:
shutil.rmtree(dir_path)
logger.debug("Removed replay directory: %s", dir_path)
except Exception as e:
logger.error("Failed to remove %s: %s", dir_path, e)
# Remove replay clip and any related files
if os.path.exists(REPLAY_DIR):
try:
shutil.rmtree(REPLAY_DIR)
logger.debug("Removed replay cache directory")
except Exception as e:
logger.error("Failed to remove replay cache: %s", e)
def cleanup_replay_cameras() -> None:
"""Remove any stale replay camera artifacts on startup.
Since replay cameras are memory-only and never written to YAML, they
won't appear in the config after a restart. This function cleans up
filesystem and database artifacts from any replay that was running when
the process stopped.
Must be called AFTER the database is bound.
"""
stale_cameras: set[str] = set()
# Scan filesystem for leftover replay artifacts to derive camera names
for dir_path in [RECORD_DIR, CLIPS_DIR, THUMB_DIR]:
if os.path.isdir(dir_path):
for entry in os.listdir(dir_path):
if entry.startswith(REPLAY_CAMERA_PREFIX):
stale_cameras.add(entry)
if os.path.isdir(REPLAY_DIR):
for entry in os.listdir(REPLAY_DIR):
if entry.startswith(REPLAY_CAMERA_PREFIX) and entry.endswith(".mp4"):
stale_cameras.add(entry.removesuffix(".mp4"))
if not stale_cameras:
return
logger.info("Cleaning up stale replay camera artifacts: %s", list(stale_cameras))
manager = DebugReplayManager()
for camera_name in stale_cameras:
manager._cleanup_db(camera_name)
manager._cleanup_files(camera_name)
if os.path.exists(REPLAY_DIR):
try:
shutil.rmtree(REPLAY_DIR)
except Exception as e:
logger.error("Failed to remove replay cache directory: %s", e)

View File

@ -421,7 +421,9 @@ class EmbeddingMaintainer(threading.Thread):
if self.config.semantic_search.enabled:
self.embeddings.update_stats()
camera_config = self.config.cameras[camera]
camera_config = self.config.cameras.get(camera)
if camera_config is None:
return
# no need to process updated objects if no processors are active
if len(self.realtime_processors) == 0 and len(self.post_processors) == 0:
@ -639,7 +641,10 @@ class EmbeddingMaintainer(threading.Thread):
if not camera or camera not in self.config.cameras:
return
camera_config = self.config.cameras[camera]
camera_config = self.config.cameras.get(camera)
if camera_config is None:
return
dedicated_lpr_enabled = (
camera_config.type == CameraTypeEnum.lpr
and "license_plate" not in camera_config.objects.track

View File

@ -7,6 +7,7 @@ from typing import Dict
from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber
from frigate.config import FrigateConfig
from frigate.config.classification import ObjectClassificationType
from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.events.types import EventStateEnum, EventTypeEnum
from frigate.models import Event
from frigate.util.builtin import to_relative_box
@ -146,7 +147,9 @@ class EventProcessor(threading.Thread):
if should_update_db(self.events_in_process[event_data["id"]], event_data):
updated_db = True
camera_config = self.config.cameras[camera]
camera_config = self.config.cameras.get(camera)
if camera_config is None:
return
width = camera_config.detect.width
height = camera_config.detect.height
first_detector = list(self.config.detectors.values())[0]
@ -283,6 +286,10 @@ class EventProcessor(threading.Thread):
def handle_external_detection(
self, event_type: EventStateEnum, event_data: Event
) -> None:
# Skip replay cameras
if event_data.get("camera", "").startswith(REPLAY_CAMERA_PREFIX):
return
if event_type == EventStateEnum.start:
event = {
Event.id: event_data["id"],

View File

@ -420,7 +420,8 @@ class BirdsEyeFrameManager:
[
cam
for cam, cam_data in self.cameras.items()
if self.config.cameras[cam].birdseye.enabled
if cam in self.config.cameras
and self.config.cameras[cam].birdseye.enabled
and self.config.cameras[cam].enabled_in_config
and self.config.cameras[cam].enabled
and cam_data["last_active_frame"] > 0
@ -723,8 +724,11 @@ class BirdsEyeFrameManager:
Update birdseye for a specific camera with new frame data.
Returns (frame_changed, layout_changed) to indicate if the frame or layout changed.
"""
# don't process if birdseye is disabled for this camera
camera_config = self.config.cameras[camera]
# don't process if camera was removed or birdseye is disabled
camera_config = self.config.cameras.get(camera)
if camera_config is None:
return False, False
force_update = False
# disabling birdseye is a little tricky

View File

@ -22,7 +22,12 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
)
from frigate.const import CACHE_DIR, CLIPS_DIR, PROCESS_PRIORITY_MED
from frigate.const import (
CACHE_DIR,
CLIPS_DIR,
PROCESS_PRIORITY_MED,
REPLAY_CAMERA_PREFIX,
)
from frigate.output.birdseye import Birdseye
from frigate.output.camera import JsmpegCamera
from frigate.output.preview import PreviewRecorder
@ -79,6 +84,32 @@ class OutputProcess(FrigateProcess):
)
self.config = config
def is_debug_replay_camera(self, camera: str) -> bool:
return camera.startswith(REPLAY_CAMERA_PREFIX)
def add_camera(
self,
camera: str,
websocket_server: WSGIServer,
jsmpeg_cameras: dict[str, JsmpegCamera],
preview_recorders: dict[str, PreviewRecorder],
preview_write_times: dict[str, float],
birdseye: Birdseye | None,
) -> None:
camera_config = self.config.cameras[camera]
jsmpeg_cameras[camera] = JsmpegCamera(
camera_config, self.stop_event, websocket_server
)
preview_recorders[camera] = PreviewRecorder(camera_config)
preview_write_times[camera] = 0
if (
birdseye is not None
and self.config.birdseye.enabled
and camera_config.birdseye.enabled
):
birdseye.add_camera(camera)
def run(self) -> None:
self.pre_run_setup(self.config.logger)
@ -118,14 +149,17 @@ class OutputProcess(FrigateProcess):
move_preview_frames("cache")
for camera, cam_config in self.config.cameras.items():
if not cam_config.enabled_in_config:
if not cam_config.enabled_in_config or self.is_debug_replay_camera(camera):
continue
jsmpeg_cameras[camera] = JsmpegCamera(
cam_config, self.stop_event, websocket_server
self.add_camera(
camera,
websocket_server,
jsmpeg_cameras,
preview_recorders,
preview_write_times,
birdseye,
)
preview_recorders[camera] = PreviewRecorder(cam_config)
preview_write_times[camera] = 0
if self.config.birdseye.enabled:
birdseye = Birdseye(self.config, self.stop_event, websocket_server)
@ -138,19 +172,15 @@ class OutputProcess(FrigateProcess):
if CameraConfigUpdateEnum.add in updates:
for camera in updates["add"]:
jsmpeg_cameras[camera] = JsmpegCamera(
self.config.cameras[camera], self.stop_event, websocket_server
)
preview_recorders[camera] = PreviewRecorder(
self.config.cameras[camera]
)
preview_write_times[camera] = 0
if (
self.config.birdseye.enabled
and self.config.cameras[camera].birdseye.enabled
):
birdseye.add_camera(camera)
if not self.is_debug_replay_camera(camera):
self.add_camera(
camera,
websocket_server,
jsmpeg_cameras,
preview_recorders,
preview_write_times,
birdseye,
)
(topic, data) = detection_subscriber.check_for_update(timeout=1)
now = datetime.datetime.now().timestamp()
@ -174,7 +204,11 @@ class OutputProcess(FrigateProcess):
_,
) = data
if not self.config.cameras[camera].enabled:
if (
camera not in self.config.cameras
or not self.config.cameras[camera].enabled
or self.is_debug_replay_camera(camera)
):
continue
frame = frame_manager.get(

View File

@ -287,11 +287,12 @@ class RecordingMaintainer(threading.Thread):
)
# publish most recently available recording time and None if disabled
camera_cfg = self.config.cameras.get(camera)
self.recordings_publisher.publish(
(
camera,
recordings[0]["start_time"].timestamp()
if self.config.cameras[camera].record.enabled
if camera_cfg and camera_cfg.record.enabled
else None,
None,
),
@ -315,9 +316,8 @@ class RecordingMaintainer(threading.Thread):
) -> Optional[Recordings]:
cache_path: str = recording["cache_path"]
start_time: datetime.datetime = recording["start_time"]
record_config = self.config.cameras[camera].record
# Just delete files if recordings are turned off
# Just delete files if camera removed or recordings are turned off
if (
camera not in self.config.cameras
or not self.config.cameras[camera].record.enabled

View File

@ -652,6 +652,9 @@ class ReviewSegmentMaintainer(threading.Thread):
if camera not in self.indefinite_events:
self.indefinite_events[camera] = {}
if camera not in self.config.cameras:
continue
if (
not self.config.cameras[camera].enabled
or not self.config.cameras[camera].record.enabled

View File

@ -340,6 +340,9 @@ def stats_snapshot(
stats["cameras"] = {}
for name, camera_stats in camera_metrics.items():
if name not in config.cameras:
continue
total_camera_fps += camera_stats.camera_fps.value
total_process_fps += camera_stats.process_fps.value
total_skipped_fps += camera_stats.skipped_fps.value

View File

@ -8,7 +8,7 @@ from pathlib import Path
from peewee import SQL, fn
from frigate.config import FrigateConfig
from frigate.const import RECORD_DIR
from frigate.const import RECORD_DIR, REPLAY_CAMERA_PREFIX
from frigate.models import Event, Recordings
from frigate.util.builtin import clear_and_unlink
@ -32,6 +32,10 @@ class StorageMaintainer(threading.Thread):
def calculate_camera_bandwidth(self) -> None:
"""Calculate an average MB/hr for each camera."""
for camera in self.config.cameras.keys():
# Skip replay cameras
if camera.startswith(REPLAY_CAMERA_PREFIX):
continue
# cameras with < 50 segments should be refreshed to keep size accurate
# when few segments are available
if self.camera_storage_stats.get(camera, {}).get("needs_refresh", True):
@ -77,6 +81,10 @@ class StorageMaintainer(threading.Thread):
usages: dict[str, dict] = {}
for camera in self.config.cameras.keys():
# Skip replay cameras
if camera.startswith(REPLAY_CAMERA_PREFIX):
continue
camera_storage = (
Recordings.select(fn.SUM(Recordings.segment_size))
.where(Recordings.camera == camera, Recordings.segment_size != 0)

View File

@ -13,6 +13,7 @@ from pydantic import Json
from frigate.api.fastapi_app import create_fastapi_app
from frigate.config import FrigateConfig
from frigate.const import BASE_DIR, CACHE_DIR
from frigate.debug_replay import DebugReplayManager
from frigate.models import Event, Recordings, ReviewSegment
from frigate.review.types import SeverityEnum
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
@ -141,6 +142,7 @@ class BaseTestHttp(unittest.TestCase):
stats,
event_metadata_publisher,
None,
DebugReplayManager(),
enforce_default_admin=False,
)

View File

@ -22,3 +22,32 @@ class TestHttpApp(BaseTestHttp):
response = client.get("/stats")
response_json = response.json()
assert response_json == self.test_stats
def test_config_set_in_memory_replaces_objects_track_list(self):
self.minimal_config["cameras"]["front_door"]["objects"] = {
"track": ["person", "car"],
}
app = super().create_app()
app.config_publisher = Mock()
with AuthTestClient(app) as client:
response = client.put(
"/config/set",
json={
"requires_restart": 0,
"skip_save": True,
"update_topic": "config/cameras/front_door/objects",
"config_data": {
"cameras": {
"front_door": {
"objects": {
"track": ["person"],
}
}
}
},
},
)
assert response.status_code == 200
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]

View File

@ -151,6 +151,22 @@ class TestConfig(unittest.TestCase):
frigate_config = FrigateConfig(**config)
assert "dog" in frigate_config.cameras["back"].objects.track
def test_deep_merge_override_replaces_list_values(self):
base = {"objects": {"track": ["person", "face"]}}
update = {"objects": {"track": ["person"]}}
merged = deep_merge(base, update, override=True)
assert merged["objects"]["track"] == ["person"]
def test_deep_merge_merge_lists_still_appends(self):
base = {"track": ["person"]}
update = {"track": ["face"]}
merged = deep_merge(base, update, override=True, merge_lists=True)
assert merged["track"] == ["person", "face"]
def test_override_birdseye(self):
config = {
"mqtt": {"host": "mqtt"},

View File

@ -86,7 +86,9 @@ class TimelineProcessor(threading.Thread):
event_data: dict[Any, Any],
) -> bool:
"""Handle object detection."""
camera_config = self.config.cameras[camera]
camera_config = self.config.cameras.get(camera)
if camera_config is None:
return False
event_id = event_data["id"]
# Base timeline entry data that all entries will share

View File

@ -690,9 +690,13 @@ class TrackedObjectProcessor(threading.Thread):
self.create_camera_state(camera)
elif "remove" in updated_topics:
for camera in updated_topics["remove"]:
camera_state = self.camera_states[camera]
camera_state.shutdown()
removed_camera_state = self.camera_states[camera]
removed_camera_state.shutdown()
self.camera_states.pop(camera)
self.camera_activity.pop(camera, None)
self.last_motion_detected.pop(camera, None)
self.requestor.send_data(UPDATE_CAMERA_ACTIVITY, self.camera_activity)
# manage camera disabled state
for camera, config in self.config.cameras.items():
@ -700,6 +704,10 @@ class TrackedObjectProcessor(threading.Thread):
continue
current_enabled = config.enabled
camera_state = self.camera_states.get(camera)
if camera_state is None:
continue
camera_state = self.camera_states[camera]
if camera_state.prev_enabled and not current_enabled:
@ -752,7 +760,11 @@ class TrackedObjectProcessor(threading.Thread):
except queue.Empty:
continue
if not self.config.cameras[camera].enabled:
camera_config = self.config.cameras.get(camera)
if camera_config is None:
continue
if not camera_config.enabled:
logger.debug(f"Camera {camera} disabled, skipping update")
continue

View File

@ -16,7 +16,7 @@ from frigate.config import (
SnapshotsConfig,
UIConfig,
)
from frigate.const import CLIPS_DIR, THUMB_DIR
from frigate.const import CLIPS_DIR, REPLAY_CAMERA_PREFIX, THUMB_DIR
from frigate.detectors.detector_config import ModelConfig
from frigate.review.types import SeverityEnum
from frigate.util.builtin import sanitize_float
@ -621,6 +621,9 @@ class TrackedObject:
if not self.camera_config.name:
return
if self.camera_config.name.startswith(REPLAY_CAMERA_PREFIX):
return
directory = os.path.join(THUMB_DIR, self.camera_config.name)
if not os.path.exists(directory):

View File

@ -84,7 +84,8 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic
"""
:param dct1: First dict to merge
:param dct2: Second dict to merge
:param override: if same key exists in both dictionaries, should override? otherwise ignore. (default=True)
:param override: if same key exists in both dictionaries, should override? otherwise ignore.
:param merge_lists: if True, lists will be merged.
:return: The merge dictionary
"""
merged = copy.deepcopy(dct1)
@ -96,6 +97,8 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic
elif isinstance(v1, list) and isinstance(v2, list):
if merge_lists:
merged[k] = v1 + v2
elif override:
merged[k] = copy.deepcopy(v2)
else:
if override:
merged[k] = copy.deepcopy(v2)

View File

@ -9,6 +9,7 @@ from typing import Any, Optional, Union
from ruamel.yaml import YAML
from frigate.const import CONFIG_DIR, EXPORT_DIR
from frigate.util.builtin import deep_merge
from frigate.util.services import get_video_properties
logger = logging.getLogger(__name__)
@ -688,3 +689,78 @@ class StreamInfoRetriever:
info = asyncio.run(get_video_properties(ffmpeg, path))
self.stream_cache[path] = info
return info
def apply_section_update(camera_config, section: str, update: dict) -> Optional[str]:
"""Merge an update dict into a camera config section and rebuild runtime variants.
For motion and object filter sections, the plain Pydantic models are rebuilt
as RuntimeMotionConfig / RuntimeFilterConfig so that rasterized numpy masks
are recomputed. This mirrors the logic in FrigateConfig.post_validation.
Args:
camera_config: The CameraConfig instance to update.
section: Config section name (e.g. "motion", "objects").
update: Nested dict of field updates to merge.
Returns:
None on success, or an error message string on failure.
"""
from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig
current = getattr(camera_config, section, None)
if current is None:
return f"Section '{section}' not found on camera '{camera_config.name}'"
try:
frame_shape = camera_config.frame_shape
if section == "motion":
merged = deep_merge(
current.model_dump(exclude_unset=True, exclude={"rasterized_mask"}),
update,
override=True,
)
camera_config.motion = RuntimeMotionConfig(
frame_shape=frame_shape, **merged
)
elif section == "objects":
merged = deep_merge(
current.model_dump(
exclude={"filters": {"__all__": {"rasterized_mask"}}}
),
update,
override=True,
)
new_objects = current.__class__.model_validate(merged)
# Preserve private _all_objects from original config
try:
new_objects._all_objects = current._all_objects
except AttributeError:
pass
# Rebuild RuntimeFilterConfig with merged global + per-object masks
for obj_name, filt in new_objects.filters.items():
merged_mask = dict(filt.mask)
if new_objects.mask:
for gid, gmask in new_objects.mask.items():
merged_mask[f"global_{gid}"] = gmask
new_objects.filters[obj_name] = RuntimeFilterConfig(
frame_shape=frame_shape,
mask=merged_mask,
**filt.model_dump(exclude_unset=True, exclude={"mask", "raw_mask"}),
)
camera_config.objects = new_objects
else:
merged = deep_merge(current.model_dump(), update, override=True)
setattr(camera_config, section, current.__class__.model_validate(merged))
except Exception:
logger.exception("Config validation error")
return "Validation error. Check logs for details."
return None

View File

@ -117,6 +117,7 @@
"button": {
"add": "Add",
"apply": "Apply",
"applying": "Applying…",
"reset": "Reset",
"undo": "Undo",
"done": "Done",
@ -252,6 +253,7 @@
"review": "Review",
"explore": "Explore",
"export": "Export",
"actions": "Actions",
"uiPlayground": "UI Playground",
"faceLibrary": "Face Library",
"classification": "Classification",

View File

@ -216,6 +216,10 @@
},
"hideObjectDetails": {
"label": "Hide object path"
},
"debugReplay": {
"label": "Debug replay",
"aria": "View this tracked object in the debug replay view"
}
},
"dialog": {

View File

@ -0,0 +1,54 @@
{
"title": "Debug Replay",
"description": "Replay camera recordings for debugging. The object list shows a time-delayed summary of detected objects and the Messages tab shows a stream of Frigate's internal messages from the replay footage.",
"websocket_messages": "Messages",
"dialog": {
"title": "Start Debug Replay",
"description": "Create a temporary replay camera that loops historical footage for debugging object detection and tracking issues. The replay camera will have the same detection configuration as the source camera. Choose a time range to begin.",
"camera": "Source Camera",
"timeRange": "Time Range",
"preset": {
"1m": "Last 1 Minute",
"5m": "Last 5 Minutes",
"timeline": "From Timeline",
"custom": "Custom"
},
"startButton": "Start Replay",
"selectFromTimeline": "Select",
"starting": "Starting replay...",
"startLabel": "Start",
"endLabel": "End",
"toast": {
"success": "Debug replay started successfully",
"error": "Failed to start debug replay: {{error}}",
"alreadyActive": "A replay session is already active",
"stopped": "Debug replay stopped",
"stopError": "Failed to stop debug replay: {{error}}",
"goToReplay": "Go to Replay"
}
},
"page": {
"noSession": "No Active Replay Session",
"noSessionDesc": "Start a debug replay from the History view by clicking the Debug Replay button in the toolbar.",
"goToRecordings": "Go to History",
"sourceCamera": "Source Camera",
"replayCamera": "Replay Camera",
"initializingReplay": "Initializing replay...",
"stoppingReplay": "Stopping replay...",
"stopReplay": "Stop Replay",
"confirmStop": {
"title": "Stop Debug Replay?",
"description": "This will stop the replay session and clean up all temporary data. Are you sure?",
"confirm": "Stop Replay",
"cancel": "Cancel"
},
"activity": "Activity",
"objects": "Object List",
"audioDetections": "Audio Detections",
"noActivity": "No activity detected",
"activeTracking": "Active tracking",
"noActiveTracking": "No active tracking",
"configuration": "Configuration",
"configurationDesc": "Fine tune motion detection and object tracking settings for the debug replay camera. No changes are saved to your Frigate configuration file."
}
}

View File

@ -1392,6 +1392,7 @@
},
"toast": {
"success": "Settings saved successfully",
"applied": "Settings applied successfully",
"successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.",
"error": "Failed to save settings",
"validationError": "Validation failed: {{message}}",

View File

@ -7,12 +7,39 @@
"logs": {
"frigate": "Frigate Logs - Frigate",
"go2rtc": "Go2RTC Logs - Frigate",
"nginx": "Nginx Logs - Frigate"
"nginx": "Nginx Logs - Frigate",
"websocket": "Messages Logs - Frigate"
}
},
"title": "System",
"metrics": "System metrics",
"logs": {
"websocket": {
"label": "Messages",
"pause": "Pause",
"resume": "Resume",
"clear": "Clear",
"filter": {
"all": "All topics",
"topics": "Topics",
"events": "Events",
"reviews": "Reviews",
"classification": "Classification",
"face_recognition": "Face Recognition",
"lpr": "LPR",
"camera_activity": "Camera activity",
"system": "System",
"camera": "Camera",
"all_cameras": "All cameras",
"cameras_count_one": "{{count}} Camera",
"cameras_count_other": "{{count}} Cameras"
},
"empty": "No messages captured yet",
"count": "{{count}} messages",
"expanded": {
"payload": "Payload"
}
},
"download": {
"label": "Download Logs"
},
@ -189,7 +216,8 @@
"cameraIsOffline": "{{camera}} is offline",
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
"detectIsVerySlow": "{{detect}} is very slow ({{speed}} ms)",
"shmTooLow": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB."
"shmTooLow": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB.",
"debugReplayActive": "Debug replay session is active"
},
"enrichments": {
"title": "Enrichments",

View File

@ -30,6 +30,7 @@ const Classification = lazy(() => import("@/pages/ClassificationModel"));
const Chat = lazy(() => import("@/pages/Chat"));
const Logs = lazy(() => import("@/pages/Logs"));
const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
const Replay = lazy(() => import("@/pages/Replay"));
function App() {
const { data: config } = useSWR<FrigateConfig>("config", {
@ -108,7 +109,8 @@ function DefaultAppView() {
<Route path="/faces" element={<FaceLibrary />} />
<Route path="/classification" element={<Classification />} />
<Route path="/chat" element={<Chat />} />
<Route path="/playground" element={<UIPlayground />} />
<Route path="/playground" element={<UIPlayground />} />{" "}
<Route path="/replay" element={<Replay />} />{" "}
</Route>
<Route path="/unauthorized" element={<AccessDenied />} />
<Route path="*" element={<Redirect to="/" />} />

View File

@ -1,5 +1,5 @@
import { baseUrl } from "./baseUrl";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import useWebSocket, { ReadyState } from "react-use-websocket";
import {
EmbeddingsReindexProgressType,
@ -17,6 +17,13 @@ import { FrigateStats } from "@/types/stats";
import { createContainer } from "react-tracked";
import useDeepMemo from "@/hooks/use-deep-memo";
export type WsFeedMessage = {
topic: string;
payload: unknown;
timestamp: number;
id: string;
};
type Update = {
topic: string;
payload: unknown;
@ -29,6 +36,9 @@ type WsState = {
type useValueReturn = [WsState, (update: Update) => void];
const wsMessageSubscribers = new Set<(msg: WsFeedMessage) => void>();
let wsMessageIdCounter = 0;
function useValue(): useValueReturn {
const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`;
@ -43,8 +53,13 @@ function useValue(): useValueReturn {
return;
}
const cameraActivity: { [key: string]: FrigateCameraState } =
JSON.parse(activityValue);
let cameraActivity: { [key: string]: Partial<FrigateCameraState> };
try {
cameraActivity = JSON.parse(activityValue);
} catch {
return;
}
if (Object.keys(cameraActivity).length === 0) {
return;
@ -53,6 +68,12 @@ function useValue(): useValueReturn {
const cameraStates: WsState = {};
Object.entries(cameraActivity).forEach(([name, state]) => {
const cameraConfig = state?.config;
if (!cameraConfig) {
return;
}
const {
record,
detect,
@ -67,7 +88,7 @@ function useValue(): useValueReturn {
detections,
object_descriptions,
review_descriptions,
} = state["config"];
} = cameraConfig;
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF";
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
@ -115,6 +136,17 @@ function useValue(): useValueReturn {
...prevState,
[data.topic]: data.payload,
}));
// Notify feed subscribers
if (wsMessageSubscribers.size > 0) {
const feedMsg: WsFeedMessage = {
topic: data.topic,
payload: data.payload,
timestamp: Date.now(),
id: String(wsMessageIdCounter++),
};
wsMessageSubscribers.forEach((cb) => cb(feedMsg));
}
}
},
onOpen: () => {
@ -740,3 +772,16 @@ export function useJobStatus(
return { payload: currentJob as Job | null };
}
export function useWsMessageSubscribe(callback: (msg: WsFeedMessage) => void) {
const callbackRef = useRef(callback);
callbackRef.current = callback;
useEffect(() => {
const handler = (msg: WsFeedMessage) => callbackRef.current(msg);
wsMessageSubscribers.add(handler);
return () => {
wsMessageSubscribers.delete(handler);
};
}, []);
}

View File

@ -26,7 +26,8 @@ export default function CameraImage({
const containerRef = useRef<HTMLDivElement | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const { name } = config ? config.cameras[camera] : "";
const cameraConfig = config?.cameras?.[camera];
const { name } = cameraConfig ?? { name: camera };
const { payload: enabledState } = useEnabledState(camera);
const enabled = enabledState ? enabledState === "ON" : true;
@ -34,15 +35,15 @@ export default function CameraImage({
useResizeObserver(containerRef);
const requestHeight = useMemo(() => {
if (!config || containerHeight == 0) {
if (!cameraConfig || containerHeight == 0) {
return 360;
}
return Math.min(
config.cameras[camera].detect.height,
cameraConfig.detect.height,
Math.round(containerHeight * (isDesktop ? 1.1 : 1.25)),
);
}, [config, camera, containerHeight]);
}, [cameraConfig, containerHeight]);
const [isPortraitImage, setIsPortraitImage] = useState(false);

View File

@ -44,6 +44,30 @@ const motion: SectionConfigOverrides = {
camera: {
restartRequired: ["frame_height"],
},
replay: {
restartRequired: [],
fieldOrder: [
"threshold",
"contour_area",
"lightning_threshold",
"improve_contrast",
],
fieldGroups: {
sensitivity: ["threshold", "contour_area"],
algorithm: ["improve_contrast"],
},
hiddenFields: [
"enabled",
"enabled_in_config",
"mask",
"raw_mask",
"mqtt_off_delay",
"delta_alpha",
"frame_alpha",
"frame_height",
],
advancedFields: ["lightning_threshold"],
},
};
export default motion;

View File

@ -99,6 +99,28 @@ const objects: SectionConfigOverrides = {
camera: {
restartRequired: [],
},
replay: {
restartRequired: [],
fieldOrder: ["track", "filters"],
fieldGroups: {
tracking: ["track"],
filtering: ["filters"],
},
hiddenFields: [
"enabled_in_config",
"alert",
"detect",
"mask",
"raw_mask",
"genai",
"genai.enabled_in_config",
"filters.*.mask",
"filters.*.raw_mask",
"filters.mask",
"filters.raw_mask",
],
advancedFields: [],
},
};
export default objects;

View File

@ -4,4 +4,5 @@ export type SectionConfigOverrides = {
base?: SectionConfig;
global?: Partial<SectionConfig>;
camera?: Partial<SectionConfig>;
replay?: Partial<SectionConfig>;
};

View File

@ -95,9 +95,9 @@ export interface SectionConfig {
}
export interface BaseSectionProps {
/** Whether this is at global or camera level */
level: "global" | "camera";
/** Camera name (required if level is "camera") */
/** Whether this is at global, camera, or replay level */
level: "global" | "camera" | "replay";
/** Camera name (required if level is "camera" or "replay") */
cameraName?: string;
/** Whether to show override indicator badge */
showOverrideIndicator?: boolean;
@ -117,6 +117,10 @@ export interface BaseSectionProps {
defaultCollapsed?: boolean;
/** Whether to show the section title (default: false for global, true for camera) */
showTitle?: boolean;
/** If true, apply config in-memory only without writing to YAML */
skipSave?: boolean;
/** If true, buttons are not sticky at the bottom */
noStickyButtons?: boolean;
/** Callback when section status changes */
onStatusChange?: (status: {
hasChanges: boolean;
@ -156,12 +160,16 @@ export function ConfigSection({
collapsible = false,
defaultCollapsed = true,
showTitle,
skipSave = false,
noStickyButtons = false,
onStatusChange,
pendingDataBySection,
onPendingDataChange,
}: ConfigSectionProps) {
// For replay level, treat as camera-level config access
const effectiveLevel = level === "replay" ? "camera" : level;
const { t, i18n } = useTranslation([
level === "camera" ? "config/cameras" : "config/global",
effectiveLevel === "camera" ? "config/cameras" : "config/global",
"config/cameras",
"views/settings",
"common",
@ -174,10 +182,10 @@ export function ConfigSection({
// Create a key for this section's pending data
const pendingDataKey = useMemo(
() =>
level === "camera" && cameraName
effectiveLevel === "camera" && cameraName
? `${cameraName}::${sectionPath}`
: sectionPath,
[level, cameraName, sectionPath],
[effectiveLevel, cameraName, sectionPath],
);
// Use pending data from parent if available, otherwise use local state
@ -222,20 +230,20 @@ export function ConfigSection({
const lastPendingDataKeyRef = useRef<string | null>(null);
const updateTopic =
level === "camera" && cameraName
effectiveLevel === "camera" && cameraName
? cameraUpdateTopicMap[sectionPath]
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
: undefined
: `config/${sectionPath}`;
// Default: show title for camera level (since it might be collapsible), hide for global
const shouldShowTitle = showTitle ?? level === "camera";
const shouldShowTitle = showTitle ?? effectiveLevel === "camera";
// Fetch config
const { data: config, mutate: refreshConfig } =
useSWR<FrigateConfig>("config");
// Get section schema using cached hook
const sectionSchema = useSectionSchema(sectionPath, level);
const sectionSchema = useSectionSchema(sectionPath, effectiveLevel);
// Apply special case handling for sections with problematic schema defaults
const modifiedSchema = useMemo(
@ -247,7 +255,7 @@ export function ConfigSection({
// Get override status
const { isOverridden, globalValue, cameraValue } = useConfigOverride({
config,
cameraName: level === "camera" ? cameraName : undefined,
cameraName: effectiveLevel === "camera" ? cameraName : undefined,
sectionPath,
compareFields: sectionConfig.overrideFields,
});
@ -256,12 +264,12 @@ export function ConfigSection({
const rawSectionValue = useMemo(() => {
if (!config) return undefined;
if (level === "camera" && cameraName) {
if (effectiveLevel === "camera" && cameraName) {
return get(config.cameras?.[cameraName], sectionPath);
}
return get(config, sectionPath);
}, [config, level, cameraName, sectionPath]);
}, [config, cameraName, sectionPath, effectiveLevel]);
const rawFormData = useMemo(() => {
if (!config) return {};
@ -328,9 +336,10 @@ export function ConfigSection({
[rawFormData, sanitizeSectionData],
);
// Clear pendingData whenever formData changes (e.g., from server refresh)
// This prevents RJSF's initial onChange call from being treated as a user edit
// Only clear if pendingData is managed locally (not by parent)
// Clear pendingData whenever the section/camera key changes (e.g., switching
// cameras) or when there is no pending data yet (initialization).
// This prevents RJSF's initial onChange call from being treated as a user edit.
// Only clear if pendingData is managed locally (not by parent).
useEffect(() => {
const pendingKeyChanged = lastPendingDataKeyRef.current !== pendingDataKey;
@ -339,15 +348,16 @@ export function ConfigSection({
isInitializingRef.current = true;
setPendingOverrides(undefined);
setDirtyOverrides(undefined);
// Reset local pending data when switching sections/cameras
if (onPendingDataChange === undefined) {
setPendingData(null);
}
} else if (!pendingData) {
isInitializingRef.current = true;
setPendingOverrides(undefined);
setDirtyOverrides(undefined);
}
if (onPendingDataChange === undefined) {
setPendingData(null);
}
}, [
onPendingDataChange,
pendingData,
@ -484,7 +494,7 @@ export function ConfigSection({
setIsSaving(true);
try {
const basePath =
level === "camera" && cameraName
effectiveLevel === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}`
: sectionPath;
const rawData = sanitizeSectionData(rawFormData);
@ -495,7 +505,7 @@ export function ConfigSection({
);
const sanitizedOverrides = sanitizeOverridesForSection(
sectionPath,
level,
effectiveLevel,
overrides,
);
@ -508,16 +518,26 @@ export function ConfigSection({
return;
}
const needsRestart = requiresRestartForOverrides(sanitizedOverrides);
const needsRestart = skipSave
? false
: requiresRestartForOverrides(sanitizedOverrides);
const configData = buildConfigDataForPath(basePath, sanitizedOverrides);
await axios.put("config/set", {
requires_restart: needsRestart ? 1 : 0,
update_topic: updateTopic,
config_data: configData,
...(skipSave ? { skip_save: true } : {}),
});
if (needsRestart) {
if (skipSave) {
toast.success(
t("toast.applied", {
ns: "views/settings",
defaultValue: "Settings applied successfully",
}),
);
} else if (needsRestart) {
statusBar?.addMessage(
"config_restart_required",
t("configForm.restartRequiredFooter", {
@ -596,7 +616,7 @@ export function ConfigSection({
}, [
sectionPath,
pendingData,
level,
effectiveLevel,
cameraName,
t,
refreshConfig,
@ -608,15 +628,16 @@ export function ConfigSection({
updateTopic,
setPendingData,
requiresRestartForOverrides,
skipSave,
]);
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
const handleResetToGlobal = useCallback(async () => {
if (level === "camera" && !cameraName) return;
if (effectiveLevel === "camera" && !cameraName) return;
try {
const basePath =
level === "camera" && cameraName
effectiveLevel === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}`
: sectionPath;
@ -632,7 +653,7 @@ export function ConfigSection({
t("toast.resetSuccess", {
ns: "views/settings",
defaultValue:
level === "global"
effectiveLevel === "global"
? "Reset to defaults"
: "Reset to global defaults",
}),
@ -651,7 +672,7 @@ export function ConfigSection({
}
}, [
sectionPath,
level,
effectiveLevel,
cameraName,
requiresRestart,
t,
@ -661,8 +682,8 @@ export function ConfigSection({
]);
const sectionValidation = useMemo(
() => getSectionValidation({ sectionPath, level, t }),
[sectionPath, level, t],
() => getSectionValidation({ sectionPath, level: effectiveLevel, t }),
[sectionPath, effectiveLevel, t],
);
const customValidate = useMemo(() => {
@ -733,7 +754,7 @@ export function ConfigSection({
// nested under the section name (e.g., `audio.label`). For global-level
// sections, keys are nested under the section name in `config/global`.
const configNamespace =
level === "camera" ? "config/cameras" : "config/global";
effectiveLevel === "camera" ? "config/cameras" : "config/global";
const title = t(`${sectionPath}.label`, {
ns: configNamespace,
defaultValue: defaultTitle,
@ -769,7 +790,7 @@ export function ConfigSection({
i18nNamespace={configNamespace}
customValidate={customValidate}
formContext={{
level,
level: effectiveLevel,
cameraName,
globalValue,
cameraValue,
@ -784,7 +805,7 @@ export function ConfigSection({
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
// For widgets that need access to full camera config (e.g., zone names)
fullCameraConfig:
level === "camera" && cameraName
effectiveLevel === "camera" && cameraName
? config?.cameras?.[cameraName]
: undefined,
fullConfig: config,
@ -804,7 +825,12 @@ export function ConfigSection({
}}
/>
<div className="sticky bottom-0 z-50 w-full border-t border-secondary bg-background pb-5 pt-0">
<div
className={cn(
"w-full border-t border-secondary bg-background pb-5 pt-0",
!noStickyButtons && "sticky bottom-0 z-50",
)}
>
<div
className={cn(
"flex flex-col items-center gap-4 pt-2 md:flex-row",
@ -822,15 +848,17 @@ export function ConfigSection({
</div>
)}
<div className="flex w-full items-center gap-2 md:w-auto">
{((level === "camera" && isOverridden) || level === "global") &&
!hasChanges && (
{((effectiveLevel === "camera" && isOverridden) ||
effectiveLevel === "global") &&
!hasChanges &&
!skipSave && (
<Button
onClick={() => setIsResetDialogOpen(true)}
variant="outline"
disabled={isSaving || disabled}
className="flex flex-1 gap-2"
>
{level === "global"
{effectiveLevel === "global"
? t("button.resetToDefault", {
ns: "common",
defaultValue: "Reset to Default",
@ -862,11 +890,18 @@ export function ConfigSection({
{isSaving ? (
<>
<ActivityIndicator className="h-4 w-4" />
{t("button.saving", {
ns: "common",
defaultValue: "Saving...",
})}
{skipSave
? t("button.applying", {
ns: "common",
defaultValue: "Applying...",
})
: t("button.saving", {
ns: "common",
defaultValue: "Saving...",
})}
</>
) : skipSave ? (
t("button.apply", { ns: "common", defaultValue: "Apply" })
) : (
t("button.save", { ns: "common", defaultValue: "Save" })
)}
@ -898,7 +933,7 @@ export function ConfigSection({
setIsResetDialogOpen(false);
}}
>
{level === "global"
{effectiveLevel === "global"
? t("button.resetToDefault", { ns: "common" })
: t("button.resetToGlobal", { ns: "common" })}
</AlertDialogAction>
@ -923,7 +958,7 @@ export function ConfigSection({
)}
<Heading as="h4">{title}</Heading>
{showOverrideIndicator &&
level === "camera" &&
effectiveLevel === "camera" &&
isOverridden && (
<Badge variant="secondary" className="text-xs">
{t("button.overridden", {
@ -967,7 +1002,7 @@ export function ConfigSection({
<div className="flex items-center gap-3">
<Heading as="h4">{title}</Heading>
{showOverrideIndicator &&
level === "camera" &&
effectiveLevel === "camera" &&
isOverridden && (
<Badge
variant="secondary"

View File

@ -93,7 +93,7 @@ export function AudioLabelSwitchesWidget(props: WidgetProps) {
getDisplayLabel: getAudioLabelDisplayName,
i18nKey: "audioLabels",
listClassName:
"max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
"relative max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
enableSearch: true,
}}
/>

View File

@ -94,7 +94,7 @@ export function ObjectLabelSwitchesWidget(props: WidgetProps) {
getDisplayLabel: getObjectLabelDisplayName,
i18nKey: "objectLabels",
listClassName:
"max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
"relative max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
}}
/>
);

View File

@ -42,7 +42,7 @@ export function ZoneSwitchesWidget(props: WidgetProps) {
getEntities: getZoneNames,
getDisplayLabel: getZoneDisplayName,
i18nKey: "zoneNames",
listClassName: "max-h-64 overflow-y-auto scrollbar-container",
listClassName: "relative max-h-64 overflow-y-auto scrollbar-container",
}}
/>
);

View File

@ -1,11 +1,11 @@
import { useState, ReactNode } from "react";
import { useState, ReactNode, useCallback } from "react";
import { SearchResult } from "@/types/search";
import { FrigateConfig } from "@/types/frigateConfig";
import { baseUrl } from "@/api/baseUrl";
import { toast } from "sonner";
import axios from "axios";
import { FiMoreVertical } from "react-icons/fi";
import { buttonVariants } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
@ -32,6 +32,7 @@ import useSWR from "swr";
import { Trans, useTranslation } from "react-i18next";
import BlurredIconButton from "../button/BlurredIconButton";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { useNavigate } from "react-router-dom";
type SearchResultActionsProps = {
searchResult: SearchResult;
@ -52,8 +53,10 @@ export default function SearchResultActions({
isContextMenu = false,
children,
}: SearchResultActionsProps) {
const { t } = useTranslation(["views/explore"]);
const { t } = useTranslation(["views/explore", "views/replay", "common"]);
const isAdmin = useIsAdmin();
const navigate = useNavigate();
const [isStarting, setIsStarting] = useState(false);
const { data: config } = useSWR<FrigateConfig>("config");
@ -84,6 +87,59 @@ export default function SearchResultActions({
});
};
const handleDebugReplay = useCallback(
(event: SearchResult) => {
setIsStarting(true);
axios
.post("debug_replay/start", {
camera: event.camera,
start_time: event.start_time,
end_time: event.end_time,
})
.then((response) => {
if (response.status === 200) {
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
position: "top-center",
});
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: (
<a href="/replay" target="_blank" rel="noopener noreferrer">
<Button>
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
</Button>
</a>
),
},
);
} else {
toast.error(t("dialog.toast.error", { error: errorMessage }), {
position: "top-center",
});
}
})
.finally(() => {
setIsStarting(false);
});
},
[navigate, t],
);
const MenuItem = isContextMenu ? ContextMenuItem : DropdownMenuItem;
const menuItems = (
@ -149,6 +205,20 @@ export default function SearchResultActions({
<span>{t("itemMenu.addTrigger.label")}</span>
</MenuItem>
)}
{searchResult.has_clip && (
<MenuItem
className="cursor-pointer"
aria-label={t("itemMenu.debugReplay.aria")}
disabled={isStarting}
onSelect={() => {
handleDebugReplay(searchResult);
}}
>
{isStarting
? t("dialog.starting", { ns: "views/replay" })
: t("itemMenu.debugReplay.label")}
</MenuItem>
)}
{isAdmin && (
<MenuItem
aria-label={t("itemMenu.deleteTrackedObject.label")}

View File

@ -0,0 +1,46 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { Button } from "../ui/button";
import { useTranslation } from "react-i18next";
import { FaFilm } from "react-icons/fa6";
type ActionsDropdownProps = {
onDebugReplayClick: () => void;
onExportClick: () => void;
};
export default function ActionsDropdown({
onDebugReplayClick,
onExportClick,
}: ActionsDropdownProps) {
const { t } = useTranslation(["components/dialog", "views/replay", "common"]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="flex items-center gap-2"
aria-label={t("menu.actions", { ns: "common" })}
size="sm"
>
<FaFilm className="size-5 text-secondary-foreground" />
<div className="text-primary">
{t("menu.actions", { ns: "common" })}
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onExportClick}>
{t("menu.export", { ns: "common" })}
</DropdownMenuItem>
<DropdownMenuItem onClick={onDebugReplayClick}>
{t("title", { ns: "views/replay" })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,240 @@
import { useMemo, useState } from "react";
import { Button } from "../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { SelectSeparator } from "../ui/select";
import { TimeRange } from "@/types/timeline";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { getUTCOffset } from "@/utils/dateUtil";
import { TimezoneAwareCalendar } from "./ReviewActivityCalendar";
import { FaArrowRight, FaCalendarAlt } from "react-icons/fa";
import { isDesktop, isIOS } from "react-device-detect";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { useTranslation } from "react-i18next";
type CustomTimeSelectorProps = {
latestTime: number;
range?: TimeRange;
setRange: (range: TimeRange | undefined) => void;
startLabel: string;
endLabel: string;
};
export function CustomTimeSelector({
latestTime,
range,
setRange,
startLabel,
endLabel,
}: CustomTimeSelectorProps) {
const { t } = useTranslation(["common"]);
const { data: config } = useSWR<FrigateConfig>("config");
// times
const timezoneOffset = useMemo(
() =>
config?.ui.timezone
? Math.round(getUTCOffset(new Date(), config.ui.timezone))
: undefined,
[config?.ui.timezone],
);
const localTimeOffset = useMemo(
() =>
Math.round(
getUTCOffset(
new Date(),
Intl.DateTimeFormat().resolvedOptions().timeZone,
),
),
[],
);
const startTime = useMemo(() => {
let time = range?.after || latestTime - 3600;
if (timezoneOffset) {
time = time + (timezoneOffset - localTimeOffset) * 60;
}
return time;
}, [range, latestTime, timezoneOffset, localTimeOffset]);
const endTime = useMemo(() => {
let time = range?.before || latestTime;
if (timezoneOffset) {
time = time + (timezoneOffset - localTimeOffset) * 60;
}
return time;
}, [range, latestTime, timezoneOffset, localTimeOffset]);
const formattedStart = useFormattedTimestamp(
startTime,
config?.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour")
: t("time.formattedTimestamp.12hour"),
);
const formattedEnd = useFormattedTimestamp(
endTime,
config?.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour")
: t("time.formattedTimestamp.12hour"),
);
const startClock = useMemo(() => {
const date = new Date(startTime * 1000);
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`;
}, [startTime]);
const endClock = useMemo(() => {
const date = new Date(endTime * 1000);
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`;
}, [endTime]);
// calendars
const [startOpen, setStartOpen] = useState(false);
const [endOpen, setEndOpen] = useState(false);
return (
<div
className={`mt-3 flex items-center rounded-lg bg-secondary text-secondary-foreground ${isDesktop ? "mx-8 gap-2 px-2" : "pl-2"}`}
>
<FaCalendarAlt />
<div className="flex flex-wrap items-center">
<Popover
open={startOpen}
onOpenChange={(open) => {
if (!open) {
setStartOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label={startLabel}
variant={startOpen ? "select" : "default"}
size="sm"
onClick={() => {
setStartOpen(true);
setEndOpen(false);
}}
>
{formattedStart}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-center" disablePortal>
<TimezoneAwareCalendar
timezone={config?.ui.timezone}
selectedDay={new Date(startTime * 1000)}
onSelect={(day) => {
if (!day) {
return;
}
setRange({
before: endTime,
after: day.getTime() / 1000 + 1,
});
}}
/>
<SelectSeparator className="bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={startClock}
step={isIOS ? "60" : "1"}
onChange={(e) => {
const clock = e.target.value;
const [hour, minute, second] = isIOS
? [...clock.split(":"), "00"]
: clock.split(":");
const start = new Date(startTime * 1000);
start.setHours(
parseInt(hour),
parseInt(minute),
parseInt(second ?? 0),
0,
);
setRange({
before: endTime,
after: start.getTime() / 1000,
});
}}
/>
</PopoverContent>
</Popover>
<FaArrowRight className="size-4 text-primary" />
<Popover
open={endOpen}
onOpenChange={(open) => {
if (!open) {
setEndOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label={endLabel}
variant={endOpen ? "select" : "default"}
size="sm"
onClick={() => {
setEndOpen(true);
setStartOpen(false);
}}
>
{formattedEnd}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-center" disablePortal>
<TimezoneAwareCalendar
timezone={config?.ui.timezone}
selectedDay={new Date(endTime * 1000)}
onSelect={(day) => {
if (!day) {
return;
}
setRange({
after: startTime,
before: day.getTime() / 1000,
});
}}
/>
<SelectSeparator className="bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="endTime"
type="time"
value={endClock}
step={isIOS ? "60" : "1"}
onChange={(e) => {
const clock = e.target.value;
const [hour, minute, second] = isIOS
? [...clock.split(":"), "00"]
: clock.split(":");
const end = new Date(endTime * 1000);
end.setHours(
parseInt(hour),
parseInt(minute),
parseInt(second ?? 0),
0,
);
setRange({
before: end.getTime() / 1000,
after: startTime,
});
}}
/>
</PopoverContent>
</Popover>
</div>
</div>
);
}

View File

@ -0,0 +1,367 @@
import { useCallback, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import { Label } from "../ui/label";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { Button } from "../ui/button";
import axios from "axios";
import { toast } from "sonner";
import { isDesktop } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { SelectSeparator } from "../ui/select";
import ActivityIndicator from "../indicators/activity-indicator";
import { LuBug, LuPlay, LuX } from "react-icons/lu";
import { ExportMode } from "@/types/filter";
import { TimeRange } from "@/types/timeline";
import { cn } from "@/lib/utils";
import { CustomTimeSelector } from "./CustomTimeSelector";
const REPLAY_TIME_OPTIONS = ["1", "5", "timeline", "custom"] as const;
type ReplayTimeOption = (typeof REPLAY_TIME_OPTIONS)[number];
type DebugReplayContentProps = {
currentTime: number;
latestTime: number;
range?: TimeRange;
selectedOption: ReplayTimeOption;
isStarting: boolean;
onSelectedOptionChange: (option: ReplayTimeOption) => void;
onStart: () => void;
onCancel: () => void;
setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void;
};
export function DebugReplayContent({
currentTime,
latestTime,
range,
selectedOption,
isStarting,
onSelectedOptionChange,
onStart,
onCancel,
setRange,
setMode,
}: DebugReplayContentProps) {
const { t } = useTranslation(["views/replay"]);
return (
<div className="w-full">
{isDesktop && (
<>
<DialogHeader>
<DialogTitle>{t("dialog.title")}</DialogTitle>
<DialogDescription>{t("dialog.description")}</DialogDescription>
</DialogHeader>
<SelectSeparator className="my-4 bg-secondary" />
</>
)}
{/* Time range */}
<div className="mt-4 flex flex-col gap-2">
<RadioGroup
className="mt-2 flex flex-col gap-4"
value={selectedOption}
onValueChange={(value) =>
onSelectedOptionChange(value as ReplayTimeOption)
}
>
{REPLAY_TIME_OPTIONS.map((opt) => (
<div key={opt} className="flex items-center gap-2">
<RadioGroupItem
className={
opt === selectedOption
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
id={`replay-${opt}`}
value={opt}
/>
<Label className="cursor-pointer" htmlFor={`replay-${opt}`}>
{opt === "custom"
? t("dialog.preset.custom")
: opt === "timeline"
? t("dialog.preset.timeline")
: t(`dialog.preset.${opt}m`)}
</Label>
</div>
))}
</RadioGroup>
</div>
{/* Custom time inputs */}
{selectedOption === "custom" && (
<CustomTimeSelector
latestTime={latestTime}
range={range}
setRange={setRange}
startLabel={t("dialog.startLabel")}
endLabel={t("dialog.endLabel")}
/>
)}
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
<DialogFooter
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-4"}
>
<div
className={`cursor-pointer p-2 text-center ${isDesktop ? "" : "w-full"}`}
onClick={onCancel}
>
{t("button.cancel", { ns: "common" })}
</div>
<Button
className={isDesktop ? "" : "w-full"}
variant="select"
size="sm"
disabled={isStarting}
onClick={() => {
if (selectedOption === "timeline") {
setRange({
after: currentTime - 30,
before: currentTime + 30,
});
setMode("timeline");
} else {
onStart();
}
}}
>
{isStarting ? <ActivityIndicator className="mr-2" /> : null}
{isStarting
? t("dialog.starting")
: selectedOption === "timeline"
? t("dialog.selectFromTimeline")
: t("dialog.startButton")}
</Button>
</DialogFooter>
</div>
);
}
type DebugReplayDialogProps = {
camera: string;
currentTime: number;
latestTime: number;
range?: TimeRange;
mode: ExportMode;
setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void;
};
export default function DebugReplayDialog({
camera,
currentTime,
latestTime,
range,
mode,
setRange,
setMode,
}: DebugReplayDialogProps) {
const { t } = useTranslation(["views/replay"]);
const navigate = useNavigate();
const [selectedOption, setSelectedOption] = useState<ReplayTimeOption>("1");
const [isStarting, setIsStarting] = useState(false);
const handleTimeOptionChange = useCallback(
(option: ReplayTimeOption) => {
setSelectedOption(option);
if (option === "custom" || option === "timeline") {
return;
}
const minutes = parseInt(option, 10);
const end = latestTime;
setRange({ after: end - minutes * 60, before: end });
},
[latestTime, setRange],
);
const handleStart = useCallback(() => {
if (!range || range.before <= range.after) {
toast.error(
t("dialog.toast.error", { error: "End time must be after start time" }),
{ position: "top-center" },
);
return;
}
setIsStarting(true);
axios
.post("debug_replay/start", {
camera: camera,
start_time: range.after,
end_time: range.before,
})
.then((response) => {
if (response.status === 200) {
toast.success(t("dialog.toast.success"), {
position: "top-center",
});
setMode("none");
setRange(undefined);
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"), {
position: "top-center",
closeButton: true,
dismissible: false,
action: (
<a href="/replay" target="_blank" rel="noopener noreferrer">
<Button>{t("dialog.toast.goToReplay")}</Button>
</a>
),
});
} else {
toast.error(t("dialog.toast.error", { error: errorMessage }), {
position: "top-center",
});
}
})
.finally(() => {
setIsStarting(false);
});
}, [camera, range, navigate, setMode, setRange, t]);
const handleCancel = useCallback(() => {
setMode("none");
setRange(undefined);
}, [setMode, setRange]);
const Overlay = isDesktop ? Dialog : Drawer;
const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
const Content = isDesktop ? DialogContent : DrawerContent;
return (
<>
<SaveDebugReplayOverlay
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
show={mode == "timeline"}
isStarting={isStarting}
onSave={handleStart}
onCancel={handleCancel}
/>
<Overlay
open={mode == "select"}
onOpenChange={(open) => {
if (!open) {
setMode("none");
}
}}
>
{!isDesktop && (
<Trigger asChild>
<Button
className="flex items-center gap-2"
aria-label={t("title")}
size="sm"
onClick={() => {
const end = latestTime;
setRange({ after: end - 60, before: end });
setSelectedOption("1");
setMode("select");
}}
>
<LuBug className="size-5 rounded-md bg-secondary-foreground fill-secondary stroke-secondary p-1" />
{isDesktop && <div className="text-primary">{t("title")}</div>}
</Button>
</Trigger>
)}
<Content
className={
isDesktop
? "max-h-[90dvh] w-auto max-w-2xl overflow-visible sm:rounded-lg md:rounded-2xl"
: "max-h-[75dvh] overflow-y-auto rounded-lg px-4 pb-4 md:rounded-2xl"
}
>
<DebugReplayContent
currentTime={currentTime}
latestTime={latestTime}
range={range}
selectedOption={selectedOption}
isStarting={isStarting}
onSelectedOptionChange={handleTimeOptionChange}
onStart={handleStart}
onCancel={handleCancel}
setRange={setRange}
setMode={setMode}
/>
</Content>
</Overlay>
</>
);
}
type SaveDebugReplayOverlayProps = {
className: string;
show: boolean;
isStarting: boolean;
onSave: () => void;
onCancel: () => void;
};
export function SaveDebugReplayOverlay({
className,
show,
isStarting,
onSave,
onCancel,
}: SaveDebugReplayOverlayProps) {
const { t } = useTranslation(["views/replay"]);
return (
<div className={className}>
<div
className={cn(
"pointer-events-auto flex items-center justify-center gap-2 rounded-lg px-2",
show ? "duration-500 animate-in slide-in-from-top" : "invisible",
"mx-auto mt-5 text-center",
)}
>
<Button
className="flex items-center gap-1 text-primary"
aria-label={t("button.cancel", { ns: "common" })}
size="sm"
disabled={isStarting}
onClick={onCancel}
>
<LuX />
{t("button.cancel", { ns: "common" })}
</Button>
<Button
className="flex items-center gap-1"
aria-label={t("dialog.startButton")}
variant="select"
size="sm"
disabled={isStarting}
onClick={onSave}
>
{isStarting ? <ActivityIndicator className="size-4" /> : <LuPlay />}
{isStarting ? t("dialog.starting") : t("dialog.startButton")}
</Button>
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import {
Dialog,
DialogContent,
@ -12,16 +12,12 @@ import { Label } from "../ui/label";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { Button } from "../ui/button";
import { ExportMode } from "@/types/filter";
import { FaArrowDown, FaArrowRight, FaCalendarAlt } from "react-icons/fa";
import { FaArrowDown } from "react-icons/fa";
import axios from "axios";
import { toast } from "sonner";
import { Input } from "../ui/input";
import { TimeRange } from "@/types/timeline";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { TimezoneAwareCalendar } from "./ReviewActivityCalendar";
import {
Select,
SelectContent,
@ -30,15 +26,15 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
import { isDesktop, isIOS, isMobile } from "react-device-detect";
import { isDesktop, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import SaveExportOverlay from "./SaveExportOverlay";
import { getUTCOffset } from "@/utils/dateUtil";
import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
import { GenericVideoPlayer } from "../player/GenericVideoPlayer";
import { useTranslation } from "react-i18next";
import { ExportCase } from "@/types/export";
import { CustomTimeSelector } from "./CustomTimeSelector";
const EXPORT_OPTIONS = [
"1",
@ -167,31 +163,33 @@ export default function ExportDialog({
}
}}
>
<Trigger asChild>
<Button
className="flex items-center gap-2"
aria-label={t("menu.export", { ns: "common" })}
size="sm"
onClick={() => {
const now = new Date(latestTime * 1000);
let start = 0;
now.setHours(now.getHours() - 1);
start = now.getTime() / 1000;
setRange({
before: latestTime,
after: start,
});
setMode("select");
}}
>
<FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" />
{isDesktop && (
<div className="text-primary">
{t("menu.export", { ns: "common" })}
</div>
)}
</Button>
</Trigger>
{!isDesktop && (
<Trigger asChild>
<Button
className="flex items-center gap-2"
aria-label={t("menu.export", { ns: "common" })}
size="sm"
onClick={() => {
const now = new Date(latestTime * 1000);
let start = 0;
now.setHours(now.getHours() - 1);
start = now.getTime() / 1000;
setRange({
before: latestTime,
after: start,
});
setMode("select");
}}
>
<FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" />
{isDesktop && (
<div className="text-primary">
{t("menu.export", { ns: "common" })}
</div>
)}
</Button>
</Trigger>
)}
<Content
className={
isDesktop
@ -332,6 +330,8 @@ export function ExportContent({
latestTime={latestTime}
range={range}
setRange={setRange}
startLabel={t("export.time.start.title")}
endLabel={t("export.time.end.title")}
/>
)}
<Input
@ -414,234 +414,6 @@ export function ExportContent({
);
}
type CustomTimeSelectorProps = {
latestTime: number;
range?: TimeRange;
setRange: (range: TimeRange | undefined) => void;
};
function CustomTimeSelector({
latestTime,
range,
setRange,
}: CustomTimeSelectorProps) {
const { t } = useTranslation(["components/dialog"]);
const { data: config } = useSWR<FrigateConfig>("config");
// times
const timezoneOffset = useMemo(
() =>
config?.ui.timezone
? Math.round(getUTCOffset(new Date(), config.ui.timezone))
: undefined,
[config?.ui.timezone],
);
const localTimeOffset = useMemo(
() =>
Math.round(
getUTCOffset(
new Date(),
Intl.DateTimeFormat().resolvedOptions().timeZone,
),
),
[],
);
const startTime = useMemo(() => {
let time = range?.after || latestTime - 3600;
if (timezoneOffset) {
time = time + (timezoneOffset - localTimeOffset) * 60;
}
return time;
}, [range, latestTime, timezoneOffset, localTimeOffset]);
const endTime = useMemo(() => {
let time = range?.before || latestTime;
if (timezoneOffset) {
time = time + (timezoneOffset - localTimeOffset) * 60;
}
return time;
}, [range, latestTime, timezoneOffset, localTimeOffset]);
const formattedStart = useFormattedTimestamp(
startTime,
config?.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour", { ns: "common" })
: t("time.formattedTimestamp.12hour", { ns: "common" }),
);
const formattedEnd = useFormattedTimestamp(
endTime,
config?.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour", { ns: "common" })
: t("time.formattedTimestamp.12hour", { ns: "common" }),
);
const startClock = useMemo(() => {
const date = new Date(startTime * 1000);
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`;
}, [startTime]);
const endClock = useMemo(() => {
const date = new Date(endTime * 1000);
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`;
}, [endTime]);
// calendars
const [startOpen, setStartOpen] = useState(false);
const [endOpen, setEndOpen] = useState(false);
return (
<div
className={`mt-3 flex items-center rounded-lg bg-secondary text-secondary-foreground ${isDesktop ? "mx-8 gap-2 px-2" : "pl-2"}`}
>
<FaCalendarAlt />
<div className="flex flex-wrap items-center">
<Popover
modal={false}
open={startOpen}
onOpenChange={(open) => {
if (!open) {
setStartOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label={t("export.time.start.title")}
variant={startOpen ? "select" : "default"}
size="sm"
onClick={() => {
setStartOpen(true);
setEndOpen(false);
}}
>
{formattedStart}
</Button>
</PopoverTrigger>
<PopoverContent
disablePortal={isDesktop}
className="flex flex-col items-center"
>
<TimezoneAwareCalendar
timezone={config?.ui.timezone}
selectedDay={new Date(startTime * 1000)}
onSelect={(day) => {
if (!day) {
return;
}
setRange({
before: endTime,
after: day.getTime() / 1000 + 1,
});
}}
/>
<SelectSeparator className="bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={startClock}
step={isIOS ? "60" : "1"}
onChange={(e) => {
const clock = e.target.value;
const [hour, minute, second] = isIOS
? [...clock.split(":"), "00"]
: clock.split(":");
const start = new Date(startTime * 1000);
start.setHours(
parseInt(hour),
parseInt(minute),
parseInt(second ?? 0),
0,
);
setRange({
before: endTime,
after: start.getTime() / 1000,
});
}}
/>
</PopoverContent>
</Popover>
<FaArrowRight className="size-4 text-primary" />
<Popover
modal={false}
open={endOpen}
onOpenChange={(open) => {
if (!open) {
setEndOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label={t("export.time.end.title")}
variant={endOpen ? "select" : "default"}
size="sm"
onClick={() => {
setEndOpen(true);
setStartOpen(false);
}}
>
{formattedEnd}
</Button>
</PopoverTrigger>
<PopoverContent
disablePortal={isDesktop}
className="flex flex-col items-center"
>
<TimezoneAwareCalendar
timezone={config?.ui.timezone}
selectedDay={new Date(endTime * 1000)}
onSelect={(day) => {
if (!day) {
return;
}
setRange({
after: startTime,
before: day.getTime() / 1000,
});
}}
/>
<SelectSeparator className="bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="endTime"
type="time"
value={endClock}
step={isIOS ? "60" : "1"}
onChange={(e) => {
const clock = e.target.value;
const [hour, minute, second] = isIOS
? [...clock.split(":"), "00"]
: clock.split(":");
const end = new Date(endTime * 1000);
end.setHours(
parseInt(hour),
parseInt(minute),
parseInt(second ?? 0),
0,
);
setRange({
before: end.getTime() / 1000,
after: startTime,
});
}}
/>
</PopoverContent>
</Popover>
</div>
</div>
);
}
type ExportPreviewDialogProps = {
camera: string;
range?: TimeRange;

View File

@ -2,8 +2,13 @@ import { useCallback, useState } from "react";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Button } from "../ui/button";
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
import { LuBug } from "react-icons/lu";
import { TimeRange } from "@/types/timeline";
import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
import {
DebugReplayContent,
SaveDebugReplayOverlay,
} from "./DebugReplayDialog";
import { ExportMode, GeneralFilter } from "@/types/filter";
import ReviewActivityCalendar from "./ReviewActivityCalendar";
import { SelectSeparator } from "../ui/select";
@ -16,19 +21,32 @@ import {
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
import { toast } from "sonner";
import axios from "axios";
import axios, { AxiosError } from "axios";
import SaveExportOverlay from "./SaveExportOverlay";
import { isIOS, isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
type DrawerMode = "none" | "select" | "export" | "calendar" | "filter";
type DrawerMode =
| "none"
| "select"
| "export"
| "calendar"
| "filter"
| "debug-replay";
const DRAWER_FEATURES = ["export", "calendar", "filter"] as const;
const DRAWER_FEATURES = [
"export",
"calendar",
"filter",
"debug-replay",
] as const;
export type DrawerFeatures = (typeof DRAWER_FEATURES)[number];
const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
"export",
"calendar",
"filter",
"debug-replay",
];
type MobileReviewSettingsDrawerProps = {
@ -45,6 +63,10 @@ type MobileReviewSettingsDrawerProps = {
recordingsSummary?: RecordingsSummary;
allLabels: string[];
allZones: string[];
debugReplayMode?: ExportMode;
debugReplayRange?: TimeRange;
setDebugReplayMode?: (mode: ExportMode) => void;
setDebugReplayRange?: (range: TimeRange | undefined) => void;
onUpdateFilter: (filter: ReviewFilter) => void;
setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void;
@ -64,13 +86,26 @@ export default function MobileReviewSettingsDrawer({
recordingsSummary,
allLabels,
allZones,
debugReplayMode = "none",
debugReplayRange,
setDebugReplayMode = () => {},
setDebugReplayRange = () => {},
onUpdateFilter,
setRange,
setMode,
setShowExportPreview,
}: MobileReviewSettingsDrawerProps) {
const { t } = useTranslation(["views/recording", "components/dialog"]);
const { t } = useTranslation([
"views/recording",
"components/dialog",
"views/replay",
]);
const navigate = useNavigate();
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
const [selectedReplayOption, setSelectedReplayOption] = useState<
"1" | "5" | "custom" | "timeline"
>("1");
const [isDebugReplayStarting, setIsDebugReplayStarting] = useState(false);
// exports
@ -140,6 +175,76 @@ export default function MobileReviewSettingsDrawer({
});
}, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]);
const onStartDebugReplay = useCallback(async () => {
if (
!debugReplayRange ||
debugReplayRange.before <= debugReplayRange.after
) {
toast.error(
t("dialog.toast.error", {
error: "End time must be after start time",
ns: "views/replay",
}),
{ position: "top-center" },
);
return;
}
setIsDebugReplayStarting(true);
try {
const response = await axios.post("debug_replay/start", {
camera: camera,
start_time: debugReplayRange.after,
end_time: debugReplayRange.before,
});
if (response.status === 200) {
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
position: "top-center",
});
setDebugReplayMode("none");
setDebugReplayRange(undefined);
setDrawerMode("none");
navigate("/replay");
}
} catch (error) {
const axiosError = error as AxiosError<{
message?: string;
detail?: string;
}>;
const errorMessage =
axiosError.response?.data?.message ||
axiosError.response?.data?.detail ||
"Unknown error";
if (axiosError.response?.status === 409) {
toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), {
position: "top-center",
});
} else {
toast.error(
t("dialog.toast.error", {
error: errorMessage,
ns: "views/replay",
}),
{
position: "top-center",
},
);
}
} finally {
setIsDebugReplayStarting(false);
}
}, [
camera,
debugReplayRange,
navigate,
setDebugReplayMode,
setDebugReplayRange,
t,
]);
// filters
const [currentFilter, setCurrentFilter] = useState<GeneralFilter>({
@ -196,6 +301,26 @@ export default function MobileReviewSettingsDrawer({
{t("filter")}
</Button>
)}
{features.includes("debug-replay") && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("title", { ns: "views/replay" })}
onClick={() => {
const now = new Date(latestTime * 1000);
now.setHours(now.getHours() - 1);
setDebugReplayRange({
after: now.getTime() / 1000,
before: latestTime,
});
setSelectedReplayOption("1");
setDrawerMode("debug-replay");
setDebugReplayMode("select");
}}
>
<LuBug className="size-5 rounded-md bg-secondary-foreground fill-secondary stroke-secondary p-1" />
{t("title", { ns: "views/replay" })}
</Button>
)}
</div>
);
} else if (drawerMode == "export") {
@ -311,6 +436,47 @@ export default function MobileReviewSettingsDrawer({
/>
</div>
);
} else if (drawerMode == "debug-replay") {
const handleTimeOptionChange = (
option: "1" | "5" | "custom" | "timeline",
) => {
setSelectedReplayOption(option);
if (option === "custom" || option === "timeline") {
return;
}
const hours = parseInt(option);
const end = latestTime;
const now = new Date(end * 1000);
now.setHours(now.getHours() - hours);
setDebugReplayRange({ after: now.getTime() / 1000, before: end });
};
content = (
<DebugReplayContent
currentTime={currentTime}
latestTime={latestTime}
range={debugReplayRange}
selectedOption={selectedReplayOption}
isStarting={isDebugReplayStarting}
onSelectedOptionChange={handleTimeOptionChange}
onStart={onStartDebugReplay}
onCancel={() => {
setDebugReplayMode("none");
setDebugReplayRange(undefined);
setDrawerMode("select");
}}
setRange={setDebugReplayRange}
setMode={(mode) => {
setDebugReplayMode(mode);
if (mode == "timeline") {
setDrawerMode("none");
}
}}
/>
);
}
return (
@ -322,6 +488,16 @@ export default function MobileReviewSettingsDrawer({
onCancel={() => setMode("none")}
onPreview={() => setShowExportPreview(true)}
/>
<SaveDebugReplayOverlay
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
show={debugReplayRange != undefined && debugReplayMode == "timeline"}
isStarting={isDebugReplayStarting}
onSave={onStartDebugReplay}
onCancel={() => {
setDebugReplayMode("none");
setDebugReplayRange(undefined);
}}
/>
<ExportPreviewDialog
camera={camera}
range={range}
@ -354,7 +530,9 @@ export default function MobileReviewSettingsDrawer({
/>
</Button>
</DrawerTrigger>
<DrawerContent className="mx-1 flex max-h-[80dvh] flex-col items-center gap-2 overflow-hidden rounded-t-2xl px-4 pb-4">
<DrawerContent
className={`mx-1 flex max-h-[80dvh] flex-col items-center gap-2 rounded-t-2xl px-4 pb-4 ${drawerMode == "export" || drawerMode == "debug-replay" ? "overflow-visible" : "overflow-hidden"}`}
>
{content}
</DrawerContent>
</Drawer>

View File

@ -12,8 +12,11 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Event } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig";
import { useState } from "react";
import { useCallback, useState } from "react";
import { useIsAdmin } from "@/hooks/use-is-admin";
import axios from "axios";
import { toast } from "sonner";
import { Button } from "../ui/button";
type EventMenuProps = {
event: Event;
@ -34,9 +37,10 @@ export default function EventMenu({
}: EventMenuProps) {
const apiHost = useApiHost();
const navigate = useNavigate();
const { t } = useTranslation("views/explore");
const { t } = useTranslation(["views/explore", "views/replay"]);
const [isOpen, setIsOpen] = useState(false);
const isAdmin = useIsAdmin();
const [isStarting, setIsStarting] = useState(false);
const handleObjectSelect = () => {
if (isSelected) {
@ -46,6 +50,59 @@ export default function EventMenu({
}
};
const handleDebugReplay = useCallback(
(event: Event) => {
setIsStarting(true);
axios
.post("debug_replay/start", {
camera: event.camera,
start_time: event.start_time,
end_time: event.end_time,
})
.then((response) => {
if (response.status === 200) {
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
position: "top-center",
});
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: (
<a href="/replay" target="_blank" rel="noopener noreferrer">
<Button>
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
</Button>
</a>
),
},
);
} else {
toast.error(t("dialog.toast.error", { error: errorMessage }), {
position: "top-center",
});
}
})
.finally(() => {
setIsStarting(false);
});
},
[navigate, t],
);
return (
<>
<span tabIndex={0} className="sr-only" />
@ -117,6 +174,19 @@ export default function EventMenu({
{t("itemMenu.findSimilar.label")}
</DropdownMenuItem>
)}
{event.has_clip && (
<DropdownMenuItem
className="cursor-pointer"
disabled={isStarting}
onSelect={() => {
handleDebugReplay(event);
}}
>
{isStarting
? t("dialog.starting", { ns: "views/replay" })
: t("itemMenu.debugReplay.label")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>

View File

@ -0,0 +1,608 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { WsFeedMessage } from "@/api/ws";
import { useWsMessageBuffer } from "@/hooks/use-ws-message-buffer";
import WsMessageRow from "./WsMessageRow";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FaEraser, FaFilter, FaPause, FaPlay, FaVideo } from "react-icons/fa";
import { FrigateConfig } from "@/types/frigateConfig";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { isMobile } from "react-device-detect";
import { isReplayCamera } from "@/utils/cameraUtil";
type TopicCategory =
| "events"
| "camera_activity"
| "system"
| "reviews"
| "classification"
| "face_recognition"
| "lpr";
const ALL_TOPIC_CATEGORIES: TopicCategory[] = [
"events",
"reviews",
"classification",
"face_recognition",
"lpr",
"camera_activity",
"system",
];
const PRESET_TOPICS: Record<TopicCategory, Set<string>> = {
events: new Set(["events", "triggers"]),
reviews: new Set(["reviews"]),
classification: new Set(["tracked_object_update"]),
face_recognition: new Set(["tracked_object_update"]),
lpr: new Set(["tracked_object_update"]),
camera_activity: new Set(["camera_activity", "audio_detections"]),
system: new Set([
"stats",
"model_state",
"job_state",
"embeddings_reindex_progress",
"audio_transcription_state",
"birdseye_layout",
]),
};
// Maps tracked_object_update payload type to TopicCategory
const TRACKED_UPDATE_TYPE_MAP: Record<string, TopicCategory> = {
classification: "classification",
face: "face_recognition",
lpr: "lpr",
};
// camera_activity preset also matches topics with camera prefix patterns
const CAMERA_ACTIVITY_TOPIC_PATTERNS = [
"/motion",
"/audio",
"/detect",
"/recordings",
"/enabled",
"/snapshots",
"/ptz",
];
function matchesCategories(
msg: WsFeedMessage,
categories: TopicCategory[] | undefined,
): boolean {
// undefined means all topics
if (!categories) return true;
const { topic, payload } = msg;
// Handle tracked_object_update with payload-based sub-categories
if (topic === "tracked_object_update") {
// payload might be a JSON string or a parsed object
let data: unknown = payload;
if (typeof data === "string") {
try {
data = JSON.parse(data);
} catch {
// not valid JSON, fall through
}
}
const updateType =
data && typeof data === "object" && "type" in data
? (data as { type: string }).type
: undefined;
if (updateType && updateType in TRACKED_UPDATE_TYPE_MAP) {
const mappedCategory = TRACKED_UPDATE_TYPE_MAP[updateType];
return categories.includes(mappedCategory);
}
// tracked_object_update with other types (e.g. "description") falls under "events"
return categories.includes("events");
}
for (const cat of categories) {
const topicSet = PRESET_TOPICS[cat];
if (topicSet.has(topic)) return true;
if (cat === "camera_activity") {
if (
CAMERA_ACTIVITY_TOPIC_PATTERNS.some((pattern) =>
topic.includes(pattern),
)
) {
return true;
}
}
}
return false;
}
type WsMessageFeedProps = {
maxSize?: number;
defaultCamera?: string;
lockedCamera?: string;
showCameraBadge?: boolean;
};
export default function WsMessageFeed({
maxSize = 500,
defaultCamera,
lockedCamera,
showCameraBadge = true,
}: WsMessageFeedProps) {
const { t } = useTranslation(["views/system"]);
const [paused, setPaused] = useState(false);
// undefined = all topics
const [selectedTopics, setSelectedTopics] = useState<
TopicCategory[] | undefined
>(undefined);
// undefined = all cameras
const [selectedCameras, setSelectedCameras] = useState<string[] | undefined>(
() => {
if (lockedCamera) return [lockedCamera];
if (defaultCamera) return [defaultCamera];
return undefined;
},
);
const { messages, clear } = useWsMessageBuffer(maxSize, paused, {
cameraFilter: selectedCameras,
});
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const availableCameras = useMemo(() => {
if (!config?.cameras) return [];
return Object.keys(config.cameras)
.filter((name) => {
const cam = config.cameras[name];
return !isReplayCamera(name) && cam.enabled_in_config;
})
.sort();
}, [config]);
const filteredMessages = useMemo(() => {
return messages.filter((msg: WsFeedMessage) => {
if (!matchesCategories(msg, selectedTopics)) return false;
return true;
});
}, [messages, selectedTopics]);
// Auto-scroll logic
const scrollContainerRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const handleScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
autoScrollRef.current = atBottom;
}, []);
useEffect(() => {
const el = scrollContainerRef.current;
if (!el || !autoScrollRef.current) return;
el.scrollTop = el.scrollHeight;
}, [filteredMessages.length]);
return (
<div className="flex size-full flex-col">
{/* Toolbar */}
<div className="flex flex-row flex-wrap items-center justify-between gap-2 border-b border-secondary p-2">
<div className="flex flex-row flex-wrap items-center gap-1">
<TopicFilterButton
selectedTopics={selectedTopics}
updateTopicFilter={setSelectedTopics}
/>
{!lockedCamera && (
<WsCamerasFilterButton
allCameras={availableCameras}
selectedCameras={selectedCameras}
updateCameraFilter={setSelectedCameras}
/>
)}
</div>
<div className="flex flex-row items-center gap-2">
<Badge variant="secondary" className="text-xs text-primary-variant">
{t("logs.websocket.count", {
count: filteredMessages.length,
})}
</Badge>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-xs"
onClick={() => setPaused(!paused)}
aria-label={
paused ? t("logs.websocket.resume") : t("logs.websocket.pause")
}
>
{paused ? (
<FaPlay className="size-2.5" />
) : (
<FaPause className="size-2.5" />
)}
{paused ? t("logs.websocket.resume") : t("logs.websocket.pause")}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-xs"
onClick={clear}
aria-label={t("logs.websocket.clear")}
>
<FaEraser className="size-2.5" />
{t("logs.websocket.clear")}
</Button>
</div>
</div>
</div>
{/* Feed area */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="scrollbar-container flex-1 overflow-y-auto"
>
{filteredMessages.length === 0 ? (
<div className="flex size-full items-center justify-center p-8 text-sm text-muted-foreground">
{t("logs.websocket.empty")}
</div>
) : (
filteredMessages.map((msg: WsFeedMessage) => (
<WsMessageRow
key={msg.id}
message={msg}
showCameraBadge={showCameraBadge}
/>
))
)}
</div>
</div>
);
}
// Topic Filter Button
type TopicFilterButtonProps = {
selectedTopics: TopicCategory[] | undefined;
updateTopicFilter: (topics: TopicCategory[] | undefined) => void;
};
function TopicFilterButton({
selectedTopics,
updateTopicFilter,
}: TopicFilterButtonProps) {
const { t } = useTranslation(["views/system"]);
const [open, setOpen] = useState(false);
const [currentTopics, setCurrentTopics] = useState<
TopicCategory[] | undefined
>(selectedTopics);
useEffect(() => {
setCurrentTopics(selectedTopics);
}, [selectedTopics]);
const isFiltered = selectedTopics !== undefined;
const trigger = (
<Button
variant={isFiltered ? "select" : "outline"}
size="sm"
className="h-7 gap-1 px-2 text-xs"
aria-label={t("logs.websocket.filter.all")}
>
<FaFilter
className={`size-2.5 ${isFiltered ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<span className={isFiltered ? "text-selected-foreground" : ""}>
{t("logs.websocket.filter.topics")}
</span>
</Button>
);
const content = (
<TopicFilterContent
currentTopics={currentTopics}
setCurrentTopics={setCurrentTopics}
onApply={() => {
updateTopicFilter(currentTopics);
setOpen(false);
}}
onReset={() => {
setCurrentTopics(undefined);
updateTopicFilter(undefined);
}}
/>
);
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open) => {
if (!open) setCurrentTopics(selectedTopics);
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<DropdownMenu
modal={false}
open={open}
onOpenChange={(open) => {
if (!open) setCurrentTopics(selectedTopics);
setOpen(open);
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>{content}</DropdownMenuContent>
</DropdownMenu>
);
}
type TopicFilterContentProps = {
currentTopics: TopicCategory[] | undefined;
setCurrentTopics: (topics: TopicCategory[] | undefined) => void;
onApply: () => void;
onReset: () => void;
};
function TopicFilterContent({
currentTopics,
setCurrentTopics,
onApply,
onReset,
}: TopicFilterContentProps) {
const { t } = useTranslation(["views/system", "common"]);
return (
<>
<div className="flex flex-col gap-2.5 p-4">
<FilterSwitch
isChecked={currentTopics === undefined}
label={t("logs.websocket.filter.all")}
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentTopics(undefined);
}
}}
/>
<DropdownMenuSeparator />
{ALL_TOPIC_CATEGORIES.map((cat) => (
<FilterSwitch
key={cat}
isChecked={currentTopics?.includes(cat) ?? false}
label={t(`logs.websocket.filter.${cat}`)}
onCheckedChange={(isChecked) => {
if (isChecked) {
const updated = currentTopics ? [...currentTopics, cat] : [cat];
setCurrentTopics(updated);
} else {
const updated = currentTopics
? currentTopics.filter((c) => c !== cat)
: [];
if (updated.length === 0) {
setCurrentTopics(undefined);
} else {
setCurrentTopics(updated);
}
}
}}
/>
))}
</div>
<DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2">
<Button
aria-label={t("button.apply", { ns: "common" })}
variant="select"
size="sm"
onClick={onApply}
>
{t("button.apply", { ns: "common" })}
</Button>
<Button
aria-label={t("button.reset", { ns: "common" })}
size="sm"
onClick={onReset}
>
{t("button.reset", { ns: "common" })}
</Button>
</div>
</>
);
}
// Camera Filter Button
type WsCamerasFilterButtonProps = {
allCameras: string[];
selectedCameras: string[] | undefined;
updateCameraFilter: (cameras: string[] | undefined) => void;
};
function WsCamerasFilterButton({
allCameras,
selectedCameras,
updateCameraFilter,
}: WsCamerasFilterButtonProps) {
const { t } = useTranslation(["views/system", "common"]);
const [open, setOpen] = useState(false);
const [currentCameras, setCurrentCameras] = useState<string[] | undefined>(
selectedCameras,
);
useEffect(() => {
setCurrentCameras(selectedCameras);
}, [selectedCameras]);
const isFiltered = selectedCameras !== undefined;
const trigger = (
<Button
variant={isFiltered ? "select" : "outline"}
size="sm"
className="h-7 gap-1 px-2 text-xs"
aria-label={t("logs.websocket.filter.all_cameras")}
>
<FaVideo
className={`size-2.5 ${isFiltered ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<span className={isFiltered ? "text-selected-foreground" : ""}>
{!selectedCameras
? t("logs.websocket.filter.all_cameras")
: t("logs.websocket.filter.cameras_count", {
count: selectedCameras.length,
})}
</span>
</Button>
);
const content = (
<WsCamerasFilterContent
allCameras={allCameras}
currentCameras={currentCameras}
setCurrentCameras={setCurrentCameras}
onApply={() => {
updateCameraFilter(currentCameras);
setOpen(false);
}}
onReset={() => {
setCurrentCameras(undefined);
updateCameraFilter(undefined);
}}
/>
);
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open) => {
if (!open) setCurrentCameras(selectedCameras);
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<DropdownMenu
modal={false}
open={open}
onOpenChange={(open) => {
if (!open) setCurrentCameras(selectedCameras);
setOpen(open);
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>{content}</DropdownMenuContent>
</DropdownMenu>
);
}
type WsCamerasFilterContentProps = {
allCameras: string[];
currentCameras: string[] | undefined;
setCurrentCameras: (cameras: string[] | undefined) => void;
onApply: () => void;
onReset: () => void;
};
function WsCamerasFilterContent({
allCameras,
currentCameras,
setCurrentCameras,
onApply,
onReset,
}: WsCamerasFilterContentProps) {
const { t } = useTranslation(["views/system", "common"]);
return (
<>
<div className="scrollbar-container flex max-h-[60dvh] flex-col gap-2.5 overflow-y-auto p-4">
<FilterSwitch
isChecked={currentCameras === undefined}
label={t("logs.websocket.filter.all_cameras")}
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentCameras(undefined);
}
}}
/>
<DropdownMenuSeparator />
{allCameras.map((cam) => (
<FilterSwitch
key={cam}
isChecked={currentCameras?.includes(cam) ?? false}
label={cam}
type="camera"
onCheckedChange={(isChecked) => {
if (isChecked) {
const updated = currentCameras ? [...currentCameras] : [];
if (!updated.includes(cam)) {
updated.push(cam);
}
setCurrentCameras(updated);
} else {
const updated = currentCameras ? [...currentCameras] : [];
if (updated.length > 1) {
updated.splice(updated.indexOf(cam), 1);
setCurrentCameras(updated);
}
}
}}
/>
))}
</div>
<DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2">
<Button
aria-label={t("button.apply", { ns: "common" })}
variant="select"
size="sm"
disabled={currentCameras?.length === 0}
onClick={onApply}
>
{t("button.apply", { ns: "common" })}
</Button>
<Button
aria-label={t("button.reset", { ns: "common" })}
size="sm"
onClick={onReset}
>
{t("button.reset", { ns: "common" })}
</Button>
</div>
</>
);
}

View File

@ -0,0 +1,433 @@
import { memo, useCallback, useState } from "react";
import { WsFeedMessage } from "@/api/ws";
import { cn } from "@/lib/utils";
import { ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { extractCameraName } from "@/utils/wsUtil";
import { getIconForLabel } from "@/utils/iconUtil";
import { LuCheck, LuCopy } from "react-icons/lu";
type TopicCategory = "events" | "camera_activity" | "system" | "other";
const TOPIC_CATEGORY_COLORS: Record<TopicCategory, string> = {
events: "bg-blue-500/20 text-blue-700 dark:text-blue-300 border-blue-500/30",
camera_activity:
"bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30",
system:
"bg-purple-500/20 text-purple-700 dark:text-purple-300 border-purple-500/30",
other: "bg-gray-500/20 text-gray-700 dark:text-gray-300 border-gray-500/30",
};
const EVENT_TYPE_COLORS: Record<string, string> = {
start:
"bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30",
update: "bg-cyan-500/20 text-cyan-700 dark:text-cyan-300 border-cyan-500/30",
end: "bg-red-500/20 text-red-700 dark:text-red-300 border-red-500/30",
};
const TRACKED_OBJECT_UPDATE_COLORS: Record<string, string> = {
description:
"bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/30",
face: "bg-pink-500/20 text-pink-700 dark:text-pink-300 border-pink-500/30",
lpr: "bg-yellow-500/20 text-yellow-700 dark:text-yellow-300 border-yellow-500/30",
classification:
"bg-violet-500/20 text-violet-700 dark:text-violet-300 border-violet-500/30",
};
function getEventTypeColor(eventType: string): string {
return (
EVENT_TYPE_COLORS[eventType] ||
"bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30"
);
}
function getTrackedObjectTypeColor(objectType: string): string {
return (
TRACKED_OBJECT_UPDATE_COLORS[objectType] ||
"bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30"
);
}
const EVENT_TOPICS = new Set([
"events",
"reviews",
"tracked_object_update",
"triggers",
]);
const SYSTEM_TOPICS = new Set([
"stats",
"model_state",
"job_state",
"embeddings_reindex_progress",
"audio_transcription_state",
"birdseye_layout",
]);
function getTopicCategory(topic: string): TopicCategory {
if (EVENT_TOPICS.has(topic)) return "events";
if (SYSTEM_TOPICS.has(topic)) return "system";
if (
topic === "camera_activity" ||
topic === "audio_detections" ||
topic.includes("/motion") ||
topic.includes("/audio") ||
topic.includes("/detect") ||
topic.includes("/recordings") ||
topic.includes("/enabled") ||
topic.includes("/snapshots") ||
topic.includes("/ptz")
) {
return "camera_activity";
}
return "other";
}
function formatTimestamp(ts: number): string {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
const ms = String(d.getMilliseconds()).padStart(3, "0");
return `${hh}:${mm}:${ss}.${ms}`;
}
function getPayloadSummary(
topic: string,
payload: unknown,
hideType: boolean = false,
): string {
if (payload === null || payload === undefined) return "";
try {
const data = typeof payload === "string" ? JSON.parse(payload) : payload;
if (typeof data === "object" && data !== null) {
// Topic-specific summary handlers
if (topic === "tracked_object_update") {
return getTrackedObjectUpdateSummary(data);
}
if ("type" in data && "label" in (data.after || data)) {
const after = data.after || data;
const parts: string[] = [];
if (!hideType) {
parts.push(`type: ${data.type}`);
}
parts.push(`label: ${after.label || "?"}`);
// Add sub_label for events topic if present
if (topic === "events" && after.sub_label) {
parts.push(`sub_label: ${after.sub_label}`);
}
return parts.join(", ");
}
if ("type" in data && "camera" in data) {
if (hideType) {
return `camera: ${data.camera}`;
}
return `type: ${data.type}, camera: ${data.camera}`;
}
const keys = Object.keys(data);
if (keys.length <= 3) {
return keys
.map((k) => {
const v = data[k];
if (typeof v === "string" || typeof v === "number") {
return `${k}: ${v}`;
}
return k;
})
.join(", ");
}
return `{${keys.length} keys}`;
}
const str = String(data);
return str.length > 80 ? str.slice(0, 80) + "…" : str;
} catch {
const str = String(payload);
return str.length > 80 ? str.slice(0, 80) + "…" : str;
}
}
function getTrackedObjectUpdateSummary(data: unknown): string {
if (typeof data !== "object" || data === null) return "";
const obj = data as Record<string, unknown>;
const type = obj.type as string;
switch (type) {
case "description":
return obj.description ? `${obj.description}` : "no description";
case "face": {
const name = obj.name as string | undefined;
return name || "unknown";
}
case "lpr": {
const name = obj.name as string | undefined;
const plate = obj.plate as string | undefined;
return name || plate || "unknown";
}
case "classification": {
const parts: string[] = [];
const model = obj.model as string | undefined;
const subLabel = obj.sub_label as string | undefined;
const attribute = obj.attribute as string | undefined;
if (model) parts.push(`model: ${model}`);
if (subLabel) parts.push(`sub_label: ${subLabel}`);
if (attribute) parts.push(`attribute: ${attribute}`);
return parts.length > 0 ? parts.join(", ") : "classification";
}
default:
return type || "unknown";
}
}
function extractTypeForBadge(payload: unknown): string | null {
if (payload === null || payload === undefined) return null;
try {
const data = typeof payload === "string" ? JSON.parse(payload) : payload;
if (typeof data === "object" && data !== null && "type" in data) {
return data.type as string;
}
} catch {
// ignore
}
return null;
}
function shouldShowTypeBadge(type: string | null): boolean {
if (!type) return false;
return true;
}
function shouldShowSummary(topic: string): boolean {
// Hide summary for reviews topic
return topic !== "reviews";
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function highlightJson(value: unknown): string {
// Try to auto-parse JSON strings
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
if (typeof parsed === "object" && parsed !== null) {
value = parsed;
}
} catch {
// not JSON
}
}
const raw = JSON.stringify(value, null, 2) ?? String(value);
// Single regex pass to colorize JSON tokens
return raw.replace(
/("(?:[^"\\]|\\.)*")\s*:|("(?:[^"\\]|\\.)*")|(true|false|null)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
(match, key: string, str: string, keyword: string, num: string) => {
if (key) {
return `<span class="text-indigo-400">${escapeHtml(key)}</span>:`;
}
if (str) {
const content = escapeHtml(str);
return `<span class="text-green-500">${content}</span>`;
}
if (keyword) {
return `<span class="text-orange-500">${keyword}</span>`;
}
if (num) {
return `<span class="text-cyan-500">${num}</span>`;
}
return match;
},
);
}
function CopyJsonButton({ payload }: { payload: unknown }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const text =
typeof payload === "string"
? payload
: JSON.stringify(payload, null, 2);
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
},
[payload],
);
return (
<button
onClick={handleCopy}
className="rounded p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Copy JSON"
>
{copied ? (
<LuCheck className="size-3.5 text-green-500" />
) : (
<LuCopy className="size-3.5" />
)}
</button>
);
}
type WsMessageRowProps = {
message: WsFeedMessage;
showCameraBadge?: boolean;
};
const WsMessageRow = memo(function WsMessageRow({
message,
showCameraBadge = true,
}: WsMessageRowProps) {
const { t } = useTranslation(["views/system"]);
const [expanded, setExpanded] = useState(false);
const category = getTopicCategory(message.topic);
const cameraName = extractCameraName(message);
const messageType = extractTypeForBadge(message.payload);
const showTypeBadge = shouldShowTypeBadge(messageType);
const summary = getPayloadSummary(message.topic, message.payload);
const eventLabel = (() => {
try {
const data =
typeof message.payload === "string"
? JSON.parse(message.payload)
: message.payload;
if (typeof data === "object" && data !== null) {
return (data.after?.label as string) || (data.label as string) || null;
}
} catch {
// ignore
}
return null;
})();
const parsedPayload = (() => {
try {
return typeof message.payload === "string"
? JSON.parse(message.payload)
: message.payload;
} catch {
return message.payload;
}
})();
const handleToggle = useCallback(() => {
setExpanded((prev) => !prev);
}, []);
// Determine which color function to use based on topic
const getTypeBadgeColor = (type: string | null) => {
if (!type) return "";
if (message.topic === "tracked_object_update") {
return getTrackedObjectTypeColor(type);
}
return getEventTypeColor(type);
};
return (
<div className="border-b border-secondary/50">
<div
className={cn(
"flex cursor-pointer items-center gap-2 px-2 py-1.5 transition-colors hover:bg-muted/50",
expanded && "bg-muted/30",
)}
onClick={handleToggle}
>
<ChevronRight
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
expanded && "rotate-90",
)}
/>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{formatTimestamp(message.timestamp)}
</span>
<span
className={cn(
"shrink-0 rounded border px-1.5 py-0.5 font-mono text-xs",
TOPIC_CATEGORY_COLORS[category],
)}
>
{message.topic}
</span>
{showTypeBadge && messageType && (
<span
className={cn(
"shrink-0 rounded border px-1.5 py-0.5 text-xs",
getTypeBadgeColor(messageType),
)}
>
{messageType}
</span>
)}
{showCameraBadge && cameraName && (
<span className="shrink-0 rounded bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground">
{cameraName}
</span>
)}
{eventLabel && (
<span className="shrink-0">
{getIconForLabel(
eventLabel,
"object",
"size-3.5 text-primary-variant",
)}
</span>
)}
{shouldShowSummary(message.topic) && (
<span className="min-w-0 truncate text-xs text-muted-foreground">
{summary}
</span>
)}
</div>
{expanded && (
<div className="border-t border-secondary/30 bg-background_alt/50 px-4 py-2">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t("logs.websocket.expanded.payload")}
</span>
<CopyJsonButton payload={parsedPayload} />
</div>
<pre
className="scrollbar-container max-h-[60vh] overflow-auto rounded bg-background p-2 font-mono text-[11px] leading-relaxed"
dangerouslySetInnerHTML={{ __html: highlightJson(parsedPayload) }}
/>
</div>
)}
</div>
);
});
export default WsMessageRow;

View File

@ -2,6 +2,7 @@ import { useContext } from "react";
import { AuthContext } from "@/context/auth-context";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { isReplayCamera } from "@/utils/cameraUtil";
export function useAllowedCameras() {
const { auth } = useContext(AuthContext);
@ -14,9 +15,11 @@ export function useAllowedCameras() {
auth.user?.role === "admin" ||
!auth.isAuthenticated // anonymous internal port
) {
// return all cameras
return config?.cameras ? Object.keys(config.cameras) : [];
// return all cameras, excluding replay cameras
return config?.cameras
? Object.keys(config.cameras).filter((name) => !isReplayCamera(name))
: [];
}
return auth.allowedCameras || [];
return (auth.allowedCameras || []).filter((name) => !isReplayCamera(name));
}

View File

@ -26,7 +26,7 @@ type useCameraActivityReturn = {
};
export function useCameraActivity(
camera: CameraConfig,
camera: CameraConfig | undefined,
revalidateOnFocus: boolean = true,
): useCameraActivityReturn {
const { data: config } = useSWR<FrigateConfig>("config", {
@ -47,7 +47,7 @@ export function useCameraActivity(
// init camera activity
const { payload: updatedCameraState } = useInitialCameraState(
camera.name,
camera?.name ?? "",
revalidateOnFocus,
);
useEffect(() => {
@ -60,7 +60,7 @@ export function useCameraActivity(
const memoizedAudioState = useDeepMemo(updatedAudioState);
useEffect(() => {
if (memoizedAudioState) {
if (memoizedAudioState && camera?.name) {
setAudioDetections(memoizedAudioState[camera.name]);
}
}, [memoizedAudioState, camera]);
@ -72,8 +72,8 @@ export function useCameraActivity(
[objects],
);
const { payload: cameraEnabled } = useEnabledState(camera.name);
const { payload: detectingMotion } = useMotionActivity(camera.name);
const { payload: cameraEnabled } = useEnabledState(camera?.name ?? "");
const { payload: detectingMotion } = useMotionActivity(camera?.name ?? "");
const { payload: event } = useFrigateEvents();
const updatedEvent = useDeepMemo(event);
@ -91,7 +91,7 @@ export function useCameraActivity(
return;
}
if (updatedEvent.after.camera !== camera.name) {
if (!camera?.name || updatedEvent.after.camera !== camera.name) {
return;
}
@ -158,6 +158,10 @@ export function useCameraActivity(
return false;
}
if (!camera?.name) {
return false;
}
return (
cameras[camera.name]?.camera_fps == 0 && stats["service"].uptime > 60
);

View File

@ -9,6 +9,7 @@ import { useMemo } from "react";
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 { useTranslation } from "react-i18next";
@ -16,6 +17,9 @@ import { useTranslation } from "react-i18next";
export default function useStats(stats: FrigateStats | undefined) {
const { t } = useTranslation(["views/system"]);
const { data: config } = useSWR<FrigateConfig>("config");
const { data: debugReplayStatus } = useSWR("debug_replay/status", {
revalidateOnFocus: false,
});
const memoizedStats = useDeepMemo(stats);
@ -74,6 +78,11 @@ export default function useStats(stats: FrigateStats | undefined) {
return;
}
// Skip replay cameras
if (isReplayCamera(name)) {
return;
}
const cameraName = config.cameras?.[name]?.friendly_name ?? name;
if (config.cameras[name].enabled && cam["camera_fps"] == 0) {
problems.push({
@ -96,7 +105,15 @@ export default function useStats(stats: FrigateStats | undefined) {
);
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
// Skip ffmpeg warnings for replay cameras when debug replay is active
if (
!isNaN(ffmpegAvg) &&
ffmpegAvg >= CameraFfmpegThreshold.error &&
!(
debugReplayStatus?.active && debugReplayStatus?.replay_camera === name
)
) {
problems.push({
text: t("stats.ffmpegHighCpuUsage", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
@ -119,8 +136,19 @@ export default function useStats(stats: FrigateStats | undefined) {
}
});
// Add message if debug replay is active
if (debugReplayStatus?.active) {
problems.push({
text: t("stats.debugReplayActive", {
defaultValue: "Debug replay session is active",
}),
color: "text-selected",
relevantLink: "/replay",
});
}
return problems;
}, [config, memoizedStats, t]);
}, [config, memoizedStats, t, debugReplayStatus]);
return { potentialProblems };
}

View File

@ -0,0 +1,99 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useWsMessageSubscribe, WsFeedMessage } from "@/api/ws";
import { extractCameraName } from "@/utils/wsUtil";
type UseWsMessageBufferReturn = {
messages: WsFeedMessage[];
clear: () => void;
};
type MessageFilter = {
cameraFilter?: string | string[]; // "all", specific camera name, or array of camera names (undefined in array = all)
};
export function useWsMessageBuffer(
maxSize: number = 2000,
paused: boolean = false,
filter?: MessageFilter,
): UseWsMessageBufferReturn {
const bufferRef = useRef<WsFeedMessage[]>([]);
const [version, setVersion] = useState(0);
const pausedRef = useRef(paused);
const filterRef = useRef(filter);
pausedRef.current = paused;
filterRef.current = filter;
const batchTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const dirtyRef = useRef(false);
useEffect(() => {
batchTimerRef.current = setInterval(() => {
if (dirtyRef.current) {
dirtyRef.current = false;
setVersion((v) => v + 1);
}
}, 200);
return () => {
if (batchTimerRef.current) {
clearInterval(batchTimerRef.current);
}
};
}, []);
const shouldIncludeMessage = useCallback((msg: WsFeedMessage): boolean => {
const currentFilter = filterRef.current;
if (!currentFilter) return true;
// Check camera filter
const cf = currentFilter.cameraFilter;
if (cf !== undefined) {
if (Array.isArray(cf)) {
// Array of cameras: include messages matching any camera in the list
const msgCamera = extractCameraName(msg);
if (msgCamera && !cf.includes(msgCamera)) {
return false;
}
} else if (cf !== "all") {
// Single string camera filter
const msgCamera = extractCameraName(msg);
if (msgCamera !== cf) {
return false;
}
}
}
return true;
}, []);
useWsMessageSubscribe(
useCallback(
(msg: WsFeedMessage) => {
if (pausedRef.current) return;
if (!shouldIncludeMessage(msg)) return;
const buf = bufferRef.current;
buf.push(msg);
if (buf.length > maxSize) {
buf.splice(0, buf.length - maxSize);
}
dirtyRef.current = true;
},
[shouldIncludeMessage, maxSize],
),
);
const clear = useCallback(() => {
bufferRef.current = [];
setVersion((v) => v + 1);
}, []);
// version is used to trigger re-renders; we spread the buffer
// into a new array so that downstream useMemo dependencies
// see a new reference and recompute.
// eslint-disable-next-line react-hooks/exhaustive-deps
const messages = useMemo(() => [...bufferRef.current], [version]);
return { messages, clear };
}

View File

@ -642,4 +642,4 @@ function CaseAssignmentDialog({
);
}
export default Exports;
export default Exports;

View File

@ -35,10 +35,12 @@ import { isIOS, isMobile } from "react-device-detect";
import { isPWA } from "@/utils/isPWA";
import { isInIframe } from "@/utils/isIFrame";
import { useTranslation } from "react-i18next";
import WsMessageFeed from "@/components/ws/WsMessageFeed";
function Logs() {
const { t } = useTranslation(["views/system"]);
const [logService, setLogService] = useState<LogType>("frigate");
const isWebsocket = logService === "websocket";
const tabsRef = useRef<HTMLDivElement | null>(null);
const lazyLogWrapperRef = useRef<HTMLDivElement>(null);
const [logs, setLogs] = useState<string[]>([]);
@ -216,6 +218,12 @@ function Logs() {
}, [logService, filterSeverity, t]);
useEffect(() => {
if (isWebsocket) {
setIsLoading(false);
setLogs([]);
return;
}
setIsLoading(true);
setLogs([]);
lastFetchedIndexRef.current = -1;
@ -494,116 +502,128 @@ function Logs() {
data-nav-item={item}
aria-label={`Select ${item}`}
>
<div className="smart-capitalize">{item}</div>
<div
className={item !== "websocket" ? "smart-capitalize" : ""}
>
{item === "websocket" ? t("logs.websocket.label") : item}
</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
<div className="flex items-center gap-2">
<Button
className="flex items-center justify-between gap-2"
aria-label={t("logs.copy.label")}
size="sm"
onClick={handleCopyLogs}
>
<FaCopy className="text-secondary-foreground" />
<div className="hidden text-primary md:block">
{t("logs.copy.label")}
</div>
</Button>
<Button
className="flex items-center justify-between gap-2"
aria-label={t("logs.download.label")}
size="sm"
onClick={handleDownloadLogs}
>
<FaDownload className="text-secondary-foreground" />
<div className="hidden text-primary md:block">
{t("button.download", { ns: "common" })}
</div>
</Button>
<LogSettingsButton
selectedLabels={filterSeverity}
updateLabelFilter={setFilterSeverity}
logSettings={logSettings}
setLogSettings={setLogSettings}
/>
</div>
</div>
<div className="relative my-2 flex size-full flex-col overflow-hidden whitespace-pre-wrap rounded-md border border-secondary bg-background_alt font-mono text-xs sm:p-1">
<div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
<div className="col-span-3 lg:col-span-2">
<div className="flex w-full flex-row items-center">
<div className="ml-1 min-w-16 smart-capitalize lg:min-w-20">
{t("logs.type.label")}
{!isWebsocket && (
<div className="flex items-center gap-2">
<Button
className="flex items-center justify-between gap-2"
aria-label={t("logs.copy.label")}
size="sm"
onClick={handleCopyLogs}
>
<FaCopy className="text-secondary-foreground" />
<div className="hidden text-primary md:block">
{t("logs.copy.label")}
</div>
<div className="mr-3">{t("logs.type.timestamp")}</div>
</Button>
<Button
className="flex items-center justify-between gap-2"
aria-label={t("logs.download.label")}
size="sm"
onClick={handleDownloadLogs}
>
<FaDownload className="text-secondary-foreground" />
<div className="hidden text-primary md:block">
{t("button.download", { ns: "common" })}
</div>
</Button>
<LogSettingsButton
selectedLabels={filterSeverity}
updateLabelFilter={setFilterSeverity}
logSettings={logSettings}
setLogSettings={setLogSettings}
/>
</div>
)}
</div>
{isWebsocket ? (
<div className="my-2 flex size-full flex-col overflow-hidden rounded-md border border-secondary bg-background_alt">
<WsMessageFeed maxSize={2000} />
</div>
) : (
<div className="relative my-2 flex size-full flex-col overflow-hidden whitespace-pre-wrap rounded-md border border-secondary bg-background_alt font-mono text-xs sm:p-1">
<div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
<div className="col-span-3 lg:col-span-2">
<div className="flex w-full flex-row items-center">
<div className="ml-1 min-w-16 smart-capitalize lg:min-w-20">
{t("logs.type.label")}
</div>
<div className="mr-3">{t("logs.type.timestamp")}</div>
</div>
</div>
<div
className={cn(
"flex items-center",
logService == "frigate" ? "col-span-2" : "col-span-1",
)}
>
{t("logs.type.tag")}
</div>
<div
className={cn(
"col-span-5 flex items-center",
logService == "frigate"
? "md:col-span-7 lg:col-span-8"
: "md:col-span-8 lg:col-span-9",
)}
>
<div className="flex flex-1">{t("logs.type.message")}</div>
</div>
</div>
<div
className={cn(
"flex items-center",
logService == "frigate" ? "col-span-2" : "col-span-1",
)}
>
{t("logs.type.tag")}
</div>
<div
className={cn(
"col-span-5 flex items-center",
logService == "frigate"
? "md:col-span-7 lg:col-span-8"
: "md:col-span-8 lg:col-span-9",
)}
>
<div className="flex flex-1">{t("logs.type.message")}</div>
</div>
</div>
<div ref={lazyLogWrapperRef} className="size-full">
{isLoading ? (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
) : (
<EnhancedScrollFollow
startFollowing={!isLoading}
onCustomScroll={handleScroll}
render={({ follow, onScroll }) => (
<>
{follow && !logSettings.disableStreaming && (
<div className="absolute right-1 top-3">
<Tooltip>
<TooltipTrigger>
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
</TooltipTrigger>
<TooltipContent>{t("logs.tips")}</TooltipContent>
</Tooltip>
</div>
)}
<LazyLog
ref={lazyLogRef}
enableLineNumbers={false}
selectableLines
lineClassName="text-primary bg-background"
highlightLineClassName="bg-primary/20"
onRowClick={handleRowClick}
formatPart={formatPart}
text={logs.join("\n")}
follow={follow}
onScroll={onScroll}
loadingComponent={
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
}
loading={isLoading}
/>
</>
)}
/>
)}
<div ref={lazyLogWrapperRef} className="size-full">
{isLoading ? (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
) : (
<EnhancedScrollFollow
startFollowing={!isLoading}
onCustomScroll={handleScroll}
render={({ follow, onScroll }) => (
<>
{follow && !logSettings.disableStreaming && (
<div className="absolute right-1 top-3">
<Tooltip>
<TooltipTrigger>
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
</TooltipTrigger>
<TooltipContent>{t("logs.tips")}</TooltipContent>
</Tooltip>
</div>
)}
<LazyLog
ref={lazyLogRef}
enableLineNumbers={false}
selectableLines
lineClassName="text-primary bg-background"
highlightLineClassName="bg-primary/20"
onRowClick={handleRowClick}
formatPart={formatPart}
text={logs.join("\n")}
follow={follow}
onScroll={onScroll}
loadingComponent={
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
}
loading={isLoading}
/>
</>
)}
/>
)}
</div>
</div>
</div>
)}
</div>
);
}

725
web/src/pages/Replay.tsx Normal file
View File

@ -0,0 +1,725 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage";
import { Button, buttonVariants } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { useCameraActivity } from "@/hooks/use-camera-activity";
import { cn } from "@/lib/utils";
import Heading from "@/components/ui/heading";
import { Toaster } from "@/components/ui/sonner";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { getIconForLabel } from "@/utils/iconUtil";
import { getTranslatedLabel } from "@/utils/i18n";
import { ObjectType } from "@/types/ws";
import WsMessageFeed from "@/components/ws/WsMessageFeed";
import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate";
import { LuExternalLink, LuInfo, LuSettings } from "react-icons/lu";
import { LuSquare } from "react-icons/lu";
import { MdReplay } from "react-icons/md";
import { isDesktop, isMobile } from "react-device-detect";
import Logo from "@/components/Logo";
import { Separator } from "@/components/ui/separator";
import { useDocDomain } from "@/hooks/use-doc-domain";
import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer";
import { IoMdArrowRoundBack } from "react-icons/io";
type DebugReplayStatus = {
active: boolean;
replay_camera: string | null;
source_camera: string | null;
start_time: number | null;
end_time: number | null;
live_ready: boolean;
};
type DebugOptions = {
bbox: boolean;
timestamp: boolean;
zones: boolean;
mask: boolean;
motion: boolean;
regions: boolean;
paths: boolean;
};
const DEFAULT_OPTIONS: DebugOptions = {
bbox: true,
timestamp: false,
zones: false,
mask: false,
motion: true,
regions: false,
paths: false,
};
const DEBUG_OPTION_KEYS: (keyof DebugOptions)[] = [
"bbox",
"timestamp",
"zones",
"mask",
"motion",
"regions",
"paths",
];
const DEBUG_OPTION_I18N_KEY: Record<keyof DebugOptions, string> = {
bbox: "boundingBoxes",
timestamp: "timestamp",
zones: "zones",
mask: "mask",
motion: "motion",
regions: "regions",
paths: "paths",
};
const REPLAY_INIT_SKELETON_TIMEOUT_MS = 8000;
export default function Replay() {
const { t } = useTranslation(["views/replay", "views/settings", "common"]);
const navigate = useNavigate();
const { getLocaleDocUrl } = useDocDomain();
const {
data: status,
mutate: refreshStatus,
isLoading,
} = useSWR<DebugReplayStatus>("debug_replay/status", {
refreshInterval: 1000,
});
const [isInitializing, setIsInitializing] = useState(true);
// Refresh status immediately on mount to avoid showing "no session" briefly
useEffect(() => {
const initializeStatus = async () => {
await refreshStatus();
setIsInitializing(false);
};
initializeStatus();
}, [refreshStatus]);
useEffect(() => {
if (status?.live_ready) {
setShowReplayInitSkeleton(false);
}
}, [status?.live_ready]);
const [options, setOptions] = useState<DebugOptions>(DEFAULT_OPTIONS);
const [isStopping, setIsStopping] = useState(false);
const [configDialogOpen, setConfigDialogOpen] = useState(false);
const searchParams = useMemo(() => {
const params = new URLSearchParams();
for (const key of DEBUG_OPTION_KEYS) {
params.set(key, options[key] ? "1" : "0");
}
return params;
}, [options]);
const handleSetOption = useCallback(
(key: keyof DebugOptions, value: boolean) => {
setOptions((prev) => ({ ...prev, [key]: value }));
},
[],
);
const handleStop = useCallback(() => {
setIsStopping(true);
axios
.post("debug_replay/stop")
.then(() => {
toast.success(t("dialog.toast.stopped"), {
position: "top-center",
});
refreshStatus();
navigate("/review");
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("dialog.toast.stopError", { error: errorMessage }), {
position: "top-center",
});
})
.finally(() => {
setIsStopping(false);
});
}, [navigate, refreshStatus, t]);
// Camera activity for the replay camera
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const replayCameraName = status?.replay_camera ?? "";
const replayCameraConfig = replayCameraName
? config?.cameras?.[replayCameraName]
: undefined;
const { objects } = useCameraActivity(replayCameraConfig);
const [showReplayInitSkeleton, setShowReplayInitSkeleton] = useState(false);
// debug draw
const containerRef = useRef<HTMLDivElement>(null);
const [debugDraw, setDebugDraw] = useState(false);
useEffect(() => {
if (!status?.active || !status.replay_camera) {
setShowReplayInitSkeleton(false);
return;
}
setShowReplayInitSkeleton(true);
const timeout = window.setTimeout(() => {
setShowReplayInitSkeleton(false);
}, REPLAY_INIT_SKELETON_TIMEOUT_MS);
return () => {
window.clearTimeout(timeout);
};
}, [status?.active, status?.replay_camera]);
useEffect(() => {
if (status?.live_ready) {
setShowReplayInitSkeleton(false);
}
}, [status?.live_ready]);
// Format time range for display
const timeRangeDisplay = useMemo(() => {
if (!status?.start_time || !status?.end_time) return "";
const start = new Date(status.start_time * 1000).toLocaleString();
const end = new Date(status.end_time * 1000).toLocaleString();
return `${start}${end}`;
}, [status]);
// Show loading state
if (isInitializing || (isLoading && !status?.active)) {
return (
<div className="flex size-full items-center justify-center">
<ActivityIndicator />
</div>
);
}
// No active session
if (!status?.active) {
return (
<div className="flex size-full flex-col items-center justify-center gap-4 p-8">
<MdReplay className="size-12" />
<Heading as="h2" className="text-center">
{t("page.noSession")}
</Heading>
<p className="max-w-md text-center text-muted-foreground">
{t("page.noSessionDesc")}
</p>
<Button variant="default" onClick={() => navigate("/review")}>
{t("page.goToRecordings")}
</Button>
</div>
);
}
return (
<div className="flex size-full flex-col overflow-hidden">
<Toaster position="top-center" closeButton={true} />
{/* Top bar */}
<div className="flex min-h-12 items-center justify-between border-b border-secondary px-2 py-2 md:min-h-16 md:px-3 md:py-3">
{isMobile && (
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
)}
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => navigate(-1)}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
onClick={() => setConfigDialogOpen(true)}
>
<LuSettings className="size-4" />
<span className="hidden md:inline">{t("page.configuration")}</span>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
className="flex items-center gap-2 text-white"
disabled={isStopping}
>
{isStopping && <ActivityIndicator className="size-4" />}
<span className="hidden md:inline">{t("page.stopReplay")}</span>
<LuSquare className="size-4 md:hidden" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("page.confirmStop.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("page.confirmStop.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("page.confirmStop.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleStop}
className={cn(
buttonVariants({ variant: "destructive" }),
"text-white",
)}
>
{t("page.confirmStop.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden pb-2 md:flex-row">
{/* Camera feed */}
<div className="flex max-h-[40%] px-2 pt-2 md:h-dvh md:max-h-full md:w-7/12 md:grow md:px-4 md:pt-2">
{isStopping ? (
<div className="flex size-full items-center justify-center rounded-lg bg-background_alt">
<div className="flex flex-col items-center justify-center gap-2">
<ActivityIndicator className="size-8" />
<div className="text-secondary-foreground">
{t("page.stoppingReplay")}
</div>
</div>
</div>
) : (
status.replay_camera && (
<div className="relative size-full min-h-10" ref={containerRef}>
<AutoUpdatingCameraImage
className="size-full"
cameraClasses="relative w-full h-full flex flex-col justify-start"
searchParams={searchParams}
camera={status.replay_camera}
showFps={false}
/>
{debugDraw && (
<DebugDrawingLayer
containerRef={containerRef}
cameraWidth={
config?.cameras?.[status.source_camera ?? ""]?.detect
.width ?? 1280
}
cameraHeight={
config?.cameras?.[status.source_camera ?? ""]?.detect
.height ?? 720
}
/>
)}
{showReplayInitSkeleton && (
<div className="pointer-events-none absolute inset-0 z-10 size-full rounded-lg bg-background">
<Skeleton className="size-full rounded-lg" />
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center gap-2">
<ActivityIndicator className="size-8" />
<div className="text-secondary-foreground">
{t("page.initializingReplay")}
</div>
</div>
</div>
)}
</div>
)
)}
</div>
{/* Side panel */}
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-4/12">
<div className="mb-5 flex flex-col space-y-2">
<Heading as="h3" className="mb-0">
{t("title")}
</Heading>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="smart-capitalize">{status.source_camera}</span>
{timeRangeDisplay && (
<>
<span className="hidden md:inline"></span>
<span className="hidden md:inline">{timeRangeDisplay}</span>
</>
)}
</div>
<div className="mb-5 space-y-3 text-sm text-muted-foreground">
<p>{t("description")}</p>
</div>
</div>
<Tabs defaultValue="debug" className="flex h-full w-full flex-col">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="debug">
{t("debug.debugging", { ns: "views/settings" })}
</TabsTrigger>
<TabsTrigger value="objects">{t("page.objects")}</TabsTrigger>
<TabsTrigger value="messages">
{t("websocket_messages")}
</TabsTrigger>
</TabsList>
<TabsContent value="debug" className="mt-2">
<div className="mt-2 space-y-6">
<div className="my-2.5 flex flex-col gap-2.5">
{DEBUG_OPTION_KEYS.map((key) => {
const i18nKey = DEBUG_OPTION_I18N_KEY[key];
return (
<div
key={key}
className="flex w-full flex-row items-center justify-between"
>
<div className="mb-1 flex flex-col">
<div className="flex items-center gap-2">
<Label
className="mb-0 cursor-pointer text-primary smart-capitalize"
htmlFor={`debug-${key}`}
>
{t(`debug.${i18nKey}.title`, {
ns: "views/settings",
})}
</Label>
{(key === "bbox" ||
key === "motion" ||
key === "regions" ||
key === "paths") && (
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-sm">
{key === "bbox" ? (
<>
<p className="mb-2">
<strong>
{t(
"debug.boundingBoxes.colors.label",
{
ns: "views/settings",
},
)}
</strong>
</p>
<ul className="list-disc space-y-1 pl-5">
<Trans ns="views/settings">
debug.boundingBoxes.colors.info
</Trans>
</ul>
</>
) : (
<Trans ns="views/settings">
{`debug.${i18nKey}.tips`}
</Trans>
)}
</PopoverContent>
</Popover>
)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t(`debug.${i18nKey}.desc`, {
ns: "views/settings",
})}
</div>
</div>
<Switch
id={`debug-${key}`}
className="ml-1"
checked={options[key]}
onCheckedChange={(checked) =>
handleSetOption(key, checked)
}
/>
</div>
);
})}
{isDesktop && (
<>
<Separator className="my-2" />
<div className="flex w-full flex-row items-center justify-between">
<div className="mb-2 flex flex-col">
<div className="flex items-center gap-2">
<Label
className="mb-0 cursor-pointer text-primary smart-capitalize"
htmlFor="debugdraw"
>
{t("debug.objectShapeFilterDrawing.title", {
ns: "views/settings",
})}
</Label>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-sm">
{t("debug.objectShapeFilterDrawing.tips", {
ns: "views/settings",
})}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl(
"configuration/object_filters#object-shape",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", {
ns: "common",
})}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t("debug.objectShapeFilterDrawing.desc", {
ns: "views/settings",
})}
</div>
</div>
<Switch
key={"draw"}
className="ml-1"
id="debug_draw"
checked={debugDraw}
onCheckedChange={(isChecked) => {
setDebugDraw(isChecked);
}}
/>
</div>
</>
)}
</div>
</div>
</TabsContent>
<TabsContent value="objects" className="mt-2">
<ObjectList
cameraConfig={replayCameraConfig}
objects={objects}
config={config}
/>
</TabsContent>
<TabsContent
value="messages"
className="mt-2 flex min-h-0 flex-1 flex-col"
>
<div className="flex h-full flex-col overflow-hidden rounded-md border border-secondary">
<WsMessageFeed
maxSize={2000}
lockedCamera={status.replay_camera ?? undefined}
showCameraBadge={false}
/>
</div>
</TabsContent>
</Tabs>
</div>
</div>
<Dialog open={configDialogOpen} onOpenChange={setConfigDialogOpen}>
<DialogContent className="scrollbar-container max-h-[90dvh] overflow-y-auto sm:max-w-xl md:max-w-3xl lg:max-w-4xl">
<DialogHeader>
<DialogTitle>{t("page.configuration")}</DialogTitle>
<DialogDescription className="mb-5">
{t("page.configurationDesc")}
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<ConfigSectionTemplate
sectionKey="motion"
level="replay"
cameraName={status.replay_camera ?? undefined}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
<ConfigSectionTemplate
sectionKey="objects"
level="replay"
cameraName={status.replay_camera ?? undefined}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
</div>
</DialogContent>
</Dialog>
</div>
);
}
type ObjectListProps = {
cameraConfig?: CameraConfig;
objects?: ObjectType[];
config?: FrigateConfig;
};
function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
const { t } = useTranslation(["views/settings"]);
const colormap = useMemo(() => {
if (!config) {
return;
}
return config.model?.colormap;
}, [config]);
const getColorForObjectName = useCallback(
(objectName: string) => {
return colormap && colormap[objectName]
? `rgb(${colormap[objectName][2]}, ${colormap[objectName][1]}, ${colormap[objectName][0]})`
: "rgb(128, 128, 128)";
},
[colormap],
);
if (!objects || objects.length === 0) {
return (
<div className="p-3 text-center text-sm text-muted-foreground">
{t("debug.noObjects", { ns: "views/settings" })}
</div>
);
}
return (
<div className="flex w-full flex-col gap-2">
{objects.map((obj: ObjectType) => {
return (
<div
key={obj.id}
className="flex flex-col rounded-lg bg-secondary/30 p-2"
>
<div className="flex flex-row items-center gap-3 pb-1">
<div
className="rounded-lg p-2"
style={{
backgroundColor: obj.stationary
? "rgb(110,110,110)"
: getColorForObjectName(obj.label),
}}
>
{getIconForLabel(obj.label, "object", "size-4 text-white")}
</div>
<div className="text-sm font-medium">
{getTranslatedLabel(obj.label)}
</div>
</div>
<div className="flex flex-col gap-1 pl-1 text-xs text-primary-variant">
<div className="flex items-center justify-between">
<span>
{t("debug.objectShapeFilterDrawing.score", {
ns: "views/settings",
})}
:
</span>
<span className="text-primary">
{obj.score ? (obj.score * 100).toFixed(1) : "-"}%
</span>
</div>
{obj.ratio && (
<div className="flex items-center justify-between">
<span>
{t("debug.objectShapeFilterDrawing.ratio", {
ns: "views/settings",
})}
:
</span>
<span className="text-primary">{obj.ratio.toFixed(2)}</span>
</div>
)}
{obj.area && cameraConfig && (
<div className="flex items-center justify-between">
<span>
{t("debug.objectShapeFilterDrawing.area", {
ns: "views/settings",
})}
:
</span>
<span className="text-primary">
{obj.area} px (
{(
(obj.area /
(cameraConfig.detect.width *
cameraConfig.detect.height)) *
100
).toFixed(2)}
%)
</span>
</div>
)}
</div>
</div>
);
})}
</div>
);
}

View File

@ -12,7 +12,7 @@ export type LogLine = {
content: string;
};
export const logTypes = ["frigate", "go2rtc", "nginx"] as const;
export const logTypes = ["frigate", "go2rtc", "nginx", "websocket"] as const;
export type LogType = (typeof logTypes)[number];
export type LogSettingsType = {

View File

@ -148,3 +148,15 @@ export function detectCameraAudioFeatures(
audioOutput: !!audioOutput,
};
}
const REPLAY_CAMERA_PREFIX = "_replay_";
/**
* Check if a camera name is a debug replay camera.
*
* @param name - The camera name to check
* @returns true if the camera is a replay camera
*/
export function isReplayCamera(name: string): boolean {
return name.startsWith(REPLAY_CAMERA_PREFIX);
}

View File

@ -514,13 +514,18 @@ const mergeSectionConfig = (
export function getSectionConfig(
sectionKey: string,
level: "global" | "camera",
level: "global" | "camera" | "replay",
): SectionConfig {
const entry = sectionConfigs[sectionKey];
if (!entry) {
return {};
}
const overrides = level === "global" ? entry.global : entry.camera;
const overrides =
level === "global"
? entry.global
: level === "replay"
? entry.replay
: entry.camera;
return mergeSectionConfig(entry.base, overrides);
}

53
web/src/utils/wsUtil.ts Normal file
View File

@ -0,0 +1,53 @@
import { WsFeedMessage } from "@/api/ws";
const EVENT_TOPICS = new Set([
"events",
"reviews",
"tracked_object_update",
"triggers",
]);
const SYSTEM_TOPICS = new Set([
"stats",
"model_state",
"job_state",
"embeddings_reindex_progress",
"audio_transcription_state",
"birdseye_layout",
]);
export function extractCameraName(message: WsFeedMessage): string | null {
// Try extracting from topic pattern: {camera}/motion, {camera}/audio/rms, etc.
const topicParts = message.topic.split("/");
if (
topicParts.length >= 2 &&
!EVENT_TOPICS.has(message.topic) &&
!SYSTEM_TOPICS.has(message.topic) &&
message.topic !== "camera_activity" &&
message.topic !== "audio_detections" &&
message.topic !== "restart" &&
message.topic !== "notification_test"
) {
return topicParts[0];
}
// Try extracting from payload
try {
const data =
typeof message.payload === "string"
? JSON.parse(message.payload)
: message.payload;
if (typeof data === "object" && data !== null) {
if ("camera" in data) return data.camera as string;
if ("after" in data && data.after?.camera)
return data.after.camera as string;
if ("before" in data && data.before?.camera)
return data.before.camera as string;
}
} catch {
// ignore parse errors
}
return null;
}

View File

@ -1,6 +1,8 @@
import ReviewCard from "@/components/card/ReviewCard";
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
import DebugReplayDialog from "@/components/overlay/DebugReplayDialog";
import ExportDialog from "@/components/overlay/ExportDialog";
import ActionsDropdown from "@/components/overlay/ActionsDropdown";
import PreviewPlayer, {
PreviewController,
} from "@/components/player/PreviewPlayer";
@ -199,6 +201,11 @@ export function RecordingView({
const [exportRange, setExportRange] = useState<TimeRange>();
const [showExportPreview, setShowExportPreview] = useState(false);
// debug replay
const [debugReplayMode, setDebugReplayMode] = useState<ExportMode>("none");
const [debugReplayRange, setDebugReplayRange] = useState<TimeRange>();
// move to next clip
const onClipEnded = useCallback(() => {
@ -269,7 +276,7 @@ export function RecordingView({
);
useEffect(() => {
if (scrubbing || exportRange) {
if (scrubbing || exportRange || debugReplayRange) {
if (
currentTime > currentTimeRange.before + 60 ||
currentTime < currentTimeRange.after - 60
@ -591,6 +598,23 @@ export function RecordingView({
selected={mainCamera}
onSelectCamera={onSelectCamera}
/>
{isDesktop && (
<DebugReplayDialog
camera={mainCamera}
currentTime={currentTime}
latestTime={timeRange.before}
mode={debugReplayMode}
range={debugReplayRange}
setRange={(range: TimeRange | undefined) => {
setDebugReplayRange(range);
if (range != undefined) {
mainControllerRef.current?.pause();
}
}}
setMode={setDebugReplayMode}
/>
)}
{isDesktop && (
<ExportDialog
camera={mainCamera}
@ -639,6 +663,28 @@ export function RecordingView({
setMotionOnly={() => {}}
/>
)}
{isDesktop && (
<ActionsDropdown
onDebugReplayClick={() => {
const now = new Date(timeRange.before * 1000);
now.setHours(now.getHours() - 1);
setDebugReplayRange({
after: now.getTime() / 1000,
before: timeRange.before,
});
setDebugReplayMode("select");
}}
onExportClick={() => {
const now = new Date(timeRange.before * 1000);
now.setHours(now.getHours() - 1);
setExportRange({
before: timeRange.before,
after: now.getTime() / 1000,
});
setExportMode("select");
}}
/>
)}
{isDesktop ? (
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
@ -688,6 +734,16 @@ export function RecordingView({
showExportPreview={showExportPreview}
allLabels={reviewFilterList.labels}
allZones={reviewFilterList.zones}
debugReplayMode={debugReplayMode}
debugReplayRange={debugReplayRange}
setDebugReplayMode={setDebugReplayMode}
setDebugReplayRange={(range: TimeRange | undefined) => {
setDebugReplayRange(range);
if (range != undefined) {
mainControllerRef.current?.pause();
}
}}
onUpdateFilter={updateFilter}
setRange={setExportRange}
setMode={setExportMode}
@ -758,7 +814,9 @@ export function RecordingView({
timeRange={currentTimeRange}
cameraPreviews={allPreviews ?? []}
startTimestamp={playbackStart}
hotKeys={exportMode != "select"}
hotKeys={
exportMode != "select" && debugReplayMode != "select"
}
fullscreen={fullscreen}
onTimestampUpdate={(timestamp) => {
setPlayerTime(timestamp);
@ -772,7 +830,11 @@ export function RecordingView({
onControllerReady={(controller) => {
mainControllerRef.current = controller;
}}
isScrubbing={scrubbing || exportMode == "timeline"}
isScrubbing={
scrubbing ||
exportMode == "timeline" ||
debugReplayMode == "timeline"
}
supportsFullscreen={supportsFullScreen}
setFullResolution={setFullResolution}
toggleFullscreen={toggleFullscreen}
@ -840,18 +902,29 @@ export function RecordingView({
contentRef={contentRef}
mainCamera={mainCamera}
timelineType={
(exportRange == undefined ? timelineType : "timeline") ??
"timeline"
(exportRange == undefined && debugReplayRange == undefined
? timelineType
: "timeline") ?? "timeline"
}
timeRange={timeRange}
mainCameraReviewItems={mainCameraReviewItems}
activeReviewItem={activeReviewItem}
currentTime={currentTime}
exportRange={exportMode == "timeline" ? exportRange : undefined}
exportRange={
exportMode == "timeline"
? exportRange
: debugReplayMode == "timeline"
? debugReplayRange
: undefined
}
setCurrentTime={setCurrentTime}
manuallySetCurrentTime={manuallySetCurrentTime}
setScrubbing={setScrubbing}
setExportRange={setExportRange}
setExportRange={
debugReplayMode == "timeline"
? setDebugReplayRange
: setExportRange
}
onAnalysisOpen={onAnalysisOpen}
isPlaying={mainControllerRef?.current?.isPlaying() ?? false}
/>