Merge branch 'dev' into pixel-search-clean

This commit is contained in:
Josh Hawkins 2026-03-05 17:43:44 -06:00
commit 94d5a16be7
127 changed files with 7422 additions and 3444 deletions

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:

View File

@ -32,6 +32,12 @@ from frigate.models import User
logger = logging.getLogger(__name__)
# In-memory cache to track which clients we've logged for an anonymous access event.
# Keyed by a hashed value combining remote address + user-agent. The value is
# an expiration timestamp (float).
FIRST_LOAD_TTL_SECONDS = 60 * 60 * 24 * 7 # 7 days
_first_load_seen: dict[str, float] = {}
def require_admin_by_default():
"""
@ -284,6 +290,15 @@ def get_remote_addr(request: Request):
return remote_addr or "127.0.0.1"
def _cleanup_first_load_seen() -> None:
"""Cleanup expired entries in the in-memory first-load cache."""
now = time.time()
# Build list for removal to avoid mutating dict during iteration
expired = [k for k, exp in _first_load_seen.items() if exp <= now]
for k in expired:
del _first_load_seen[k]
def get_jwt_secret() -> str:
jwt_secret = None
# check env var
@ -744,10 +759,30 @@ def profile(request: Request):
roles_dict = request.app.frigate_config.auth.roles
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
return JSONResponse(
response = JSONResponse(
content={"username": username, "role": role, "allowed_cameras": allowed_cameras}
)
if username == "anonymous":
try:
remote_addr = get_remote_addr(request)
except Exception:
remote_addr = (
request.client.host if hasattr(request, "client") else "unknown"
)
ua = request.headers.get("user-agent", "")
key_material = f"{remote_addr}|{ua}"
cache_key = hashlib.sha256(key_material.encode()).hexdigest()
_cleanup_first_load_seen()
now = time.time()
if cache_key not in _first_load_seen:
_first_load_seen[cache_key] = now + FIRST_LOAD_TTL_SECONDS
logger.info(f"Anonymous user access from {remote_addr} ua={ua[:200]}")
return response
@router.get(
"/logout",

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,
@ -33,6 +34,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
@ -66,6 +68,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")
@ -135,6 +138,7 @@ def create_fastapi_app(
app.include_router(media.router)
app.include_router(motion_search.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)
@ -146,6 +150,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

@ -24,6 +24,7 @@ from tzlocal import get_localzone_name
from frigate.api.auth import (
allow_any_authenticated,
require_camera_access,
require_role,
)
from frigate.api.defs.query.media_query_parameters import (
Extension,
@ -1005,6 +1006,23 @@ def grid_snapshot(
)
@router.delete(
"/{camera_name}/region_grid", dependencies=[Depends(require_role("admin"))]
)
def clear_region_grid(request: Request, camera_name: str):
"""Clear the region grid for a camera."""
if camera_name not in request.app.frigate_config.cameras:
return JSONResponse(
content={"success": False, "message": "Camera not found"},
status_code=404,
)
Regions.delete().where(Regions.camera == camera_name).execute()
return JSONResponse(
content={"success": True, "message": "Region grid cleared"},
)
@router.get(
"/events/{event_id}/snapshot-clean.webp",
dependencies=[Depends(require_camera_access)],

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
@ -140,6 +144,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():
@ -532,6 +539,7 @@ class FrigateApp:
set_file_limit()
# Start frigate services.
self.init_debug_replay_manager()
self.init_camera_metrics()
self.init_queues()
self.init_database()
@ -542,6 +550,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()
@ -573,6 +585,7 @@ class FrigateApp:
self.stats_emitter,
self.event_metadata_updater,
self.inter_config_updater,
self.replay_manager,
),
host="127.0.0.1",
port=5001,
@ -641,6 +654,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
@ -222,6 +236,7 @@ class AudioActivityManager:
None,
"audio",
{},
None,
),
EventMetadataTypeEnum.manual_event_create.value,
)

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

@ -1,5 +1,6 @@
"""Ollama Provider for Frigate AI."""
import json
import logging
from typing import Any, Optional
@ -108,7 +109,22 @@ class OllamaClient(GenAIClient):
if msg.get("name"):
msg_dict["name"] = msg["name"]
if msg.get("tool_calls"):
msg_dict["tool_calls"] = msg["tool_calls"]
# Ollama requires tool call arguments as dicts, but the
# conversation format (OpenAI-style) stores them as JSON
# strings. Convert back to dicts for Ollama.
ollama_tool_calls = []
for tc in msg["tool_calls"]:
func = tc.get("function") or {}
args = func.get("arguments") or {}
if isinstance(args, str):
try:
args = json.loads(args)
except (json.JSONDecodeError, TypeError):
args = {}
ollama_tool_calls.append(
{"function": {"name": func.get("name", ""), "arguments": args}}
)
msg_dict["tool_calls"] = ollama_tool_calls
request_messages.append(msg_dict)
request_params: dict[str, Any] = {
@ -120,25 +136,27 @@ class OllamaClient(GenAIClient):
request_params["stream"] = True
if tools:
request_params["tools"] = tools
if tool_choice:
request_params["tool_choice"] = (
"none"
if tool_choice == "none"
else "required"
if tool_choice == "required"
else "auto"
)
return request_params
def _message_from_response(self, response: dict[str, Any]) -> dict[str, Any]:
"""Parse Ollama chat response into {content, tool_calls, finish_reason}."""
if not response or "message" not in response:
logger.debug("Ollama response empty or missing 'message' key")
return {
"content": None,
"tool_calls": None,
"finish_reason": "error",
}
message = response["message"]
logger.debug(
"Ollama response message keys: %s, content_len=%s, thinking_len=%s, "
"tool_calls=%s, done=%s",
list(message.keys()) if hasattr(message, "keys") else "N/A",
len(message.get("content", "") or "") if message.get("content") else 0,
len(message.get("thinking", "") or "") if message.get("thinking") else 0,
bool(message.get("tool_calls")),
response.get("done"),
)
content = message.get("content", "").strip() if message.get("content") else None
tool_calls = parse_tool_calls_from_message(message)
finish_reason = "error"
@ -198,7 +216,13 @@ class OllamaClient(GenAIClient):
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
):
"""Stream chat with tools; yields content deltas then final message."""
"""Stream chat with tools; yields content deltas then final message.
When tools are provided, Ollama streaming does not include tool_calls
in the response chunks. To work around this, we use a non-streaming
call when tools are present to ensure tool calls are captured, then
emit the content as a single delta followed by the final message.
"""
if self.provider is None:
logger.warning(
"Ollama provider has not been initialized. Check your Ollama configuration."
@ -213,6 +237,27 @@ class OllamaClient(GenAIClient):
)
return
try:
# Ollama does not return tool_calls in streaming mode, so fall
# back to a non-streaming call when tools are provided.
if tools:
logger.debug(
"Ollama: tools provided, using non-streaming call for tool support"
)
request_params = self._build_request_params(
messages, tools, tool_choice, stream=False
)
async_client = OllamaAsyncClient(
host=self.genai_config.base_url,
timeout=self.timeout,
)
response = await async_client.chat(**request_params)
result = self._message_from_response(response)
content = result.get("content")
if content:
yield ("content_delta", content)
yield ("message", result)
return
request_params = self._build_request_params(
messages, tools, tool_choice, stream=True
)
@ -222,27 +267,23 @@ class OllamaClient(GenAIClient):
)
content_parts: list[str] = []
final_message: dict[str, Any] | None = None
try:
stream = await async_client.chat(**request_params)
async for chunk in stream:
if not chunk or "message" not in chunk:
continue
msg = chunk.get("message", {})
delta = msg.get("content") or ""
if delta:
content_parts.append(delta)
yield ("content_delta", delta)
if chunk.get("done"):
full_content = "".join(content_parts).strip() or None
tool_calls = parse_tool_calls_from_message(msg)
final_message = {
"content": full_content,
"tool_calls": tool_calls,
"finish_reason": "tool_calls" if tool_calls else "stop",
}
break
finally:
await async_client.close()
stream = await async_client.chat(**request_params)
async for chunk in stream:
if not chunk or "message" not in chunk:
continue
msg = chunk.get("message", {})
delta = msg.get("content") or ""
if delta:
content_parts.append(delta)
yield ("content_delta", delta)
if chunk.get("done"):
full_content = "".join(content_parts).strip() or None
final_message = {
"content": full_content,
"tool_calls": None,
"finish_reason": "stop",
}
break
if final_message is not None:
yield ("message", final_message)

View File

@ -23,21 +23,26 @@ def parse_tool_calls_from_message(
if not raw or not isinstance(raw, list):
return None
result = []
for tool_call in raw:
for idx, tool_call in enumerate(raw):
function_data = tool_call.get("function") or {}
try:
arguments_str = function_data.get("arguments") or "{}"
arguments = json.loads(arguments_str)
except (json.JSONDecodeError, KeyError, TypeError) as e:
logger.warning(
"Failed to parse tool call arguments: %s, tool: %s",
e,
function_data.get("name", "unknown"),
)
raw_arguments = function_data.get("arguments") or {}
if isinstance(raw_arguments, dict):
arguments = raw_arguments
elif isinstance(raw_arguments, str):
try:
arguments = json.loads(raw_arguments)
except (json.JSONDecodeError, KeyError, TypeError) as e:
logger.warning(
"Failed to parse tool call arguments: %s, tool: %s",
e,
function_data.get("name", "unknown"),
)
arguments = {}
else:
arguments = {}
result.append(
{
"id": tool_call.get("id", ""),
"id": tool_call.get("id", "") or f"call_{idx}",
"name": function_data.get("name", ""),
"arguments": arguments,
}

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

@ -289,11 +289,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,
),
@ -317,9 +318,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

@ -110,6 +110,7 @@ def ensure_torch_dependencies() -> bool:
"pip",
"install",
"--break-system-packages",
"setuptools<81",
"torch",
"torchvision",
],

4562
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite --host",
"postinstall": "patch-package",
"build": "tsc && vite build --base=/BASE_PATH/",
"lint": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore .",
"lint:fix": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore --fix .",
@ -33,7 +34,7 @@
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toggle": "^1.1.2",
@ -51,8 +52,7 @@
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"date-fns-tz": "^3.2.0",
"embla-carousel-react": "^8.2.0",
"framer-motion": "^11.5.4",
"framer-motion": "^12.35.0",
"hls.js": "^1.5.20",
"i18next": "^24.2.0",
"i18next-http-backend": "^3.0.1",
@ -62,30 +62,28 @@
"lodash": "^4.17.23",
"lucide-react": "^0.477.0",
"monaco-yaml": "^5.3.1",
"next-themes": "^0.3.0",
"next-themes": "^0.4.6",
"nosleep.js": "^0.12.0",
"react": "^18.3.1",
"react": "^19.2.4",
"react-apexcharts": "^1.4.1",
"react-day-picker": "^9.7.0",
"react-device-detect": "^2.2.3",
"react-dom": "^18.3.1",
"react-dom": "^19.2.4",
"react-dropzone": "^14.3.8",
"react-grid-layout": "^1.5.0",
"react-grid-layout": "^2.2.2",
"react-hook-form": "^7.52.1",
"react-i18next": "^15.2.0",
"react-icons": "^5.5.0",
"react-konva": "^18.2.10",
"react-router-dom": "^6.30.3",
"react-konva": "^19.2.3",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"react-router-dom": "^6.30.3",
"react-swipeable": "^7.0.2",
"react-tracked": "^2.0.1",
"react-transition-group": "^4.4.5",
"react-use-websocket": "^4.8.1",
"react-zoom-pan-pinch": "3.4.4",
"recoil": "^0.7.7",
"react-zoom-pan-pinch": "^3.7.0",
"remark-gfm": "^4.0.0",
"scroll-into-view-if-needed": "^3.1.0",
"sonner": "^1.5.0",
"sonner": "^2.0.7",
"sort-by": "^1.2.0",
"strftime": "^0.10.3",
"swr": "^2.3.2",
@ -93,7 +91,7 @@
"tailwind-scrollbar": "^3.1.0",
"tailwindcss-animate": "^1.0.7",
"use-long-press": "^3.2.0",
"vaul": "^0.9.1",
"vaul": "^1.1.2",
"vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.23.8"
},
@ -102,11 +100,8 @@
"@testing-library/jest-dom": "^6.6.2",
"@types/lodash": "^4.17.12",
"@types/node": "^20.14.10",
"@types/react": "^18.3.2",
"@types/react-dom": "^18.3.0",
"@types/react-grid-layout": "^1.3.5",
"@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/strftime": "^0.9.8",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
@ -117,19 +112,26 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.2.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.8",
"eslint-plugin-vitest-globals": "^1.5.0",
"fake-indexeddb": "^6.0.0",
"jest-websocket-mock": "^2.5.0",
"jsdom": "^24.1.1",
"monaco-editor": "^0.52.0",
"msw": "^2.3.5",
"patch-package": "^8.0.1",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.9",
"typescript": "^5.8.2",
"typescript": "^5.9.3",
"vite": "^6.4.1",
"vitest": "^3.0.7"
},
"overrides": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-slot": "1.2.4"
}
}

View File

@ -0,0 +1,75 @@
diff --git a/node_modules/@radix-ui/react-compose-refs/dist/index.js b/node_modules/@radix-ui/react-compose-refs/dist/index.js
index 5ba7a95..65aa7be 100644
--- a/node_modules/@radix-ui/react-compose-refs/dist/index.js
+++ b/node_modules/@radix-ui/react-compose-refs/dist/index.js
@@ -69,6 +69,31 @@ function composeRefs(...refs) {
};
}
function useComposedRefs(...refs) {
- return React.useCallback(composeRefs(...refs), refs);
+ const refsRef = React.useRef(refs);
+ React.useLayoutEffect(() => {
+ refsRef.current = refs;
+ });
+ return React.useCallback((node) => {
+ let hasCleanup = false;
+ const cleanups = refsRef.current.map((ref) => {
+ const cleanup = setRef(ref, node);
+ if (!hasCleanup && typeof cleanup === "function") {
+ hasCleanup = true;
+ }
+ return cleanup;
+ });
+ if (hasCleanup) {
+ return () => {
+ for (let i = 0; i < cleanups.length; i++) {
+ const cleanup = cleanups[i];
+ if (typeof cleanup === "function") {
+ cleanup();
+ } else {
+ setRef(refsRef.current[i], null);
+ }
+ }
+ };
+ }
+ }, []);
}
//# sourceMappingURL=index.js.map
diff --git a/node_modules/@radix-ui/react-compose-refs/dist/index.mjs b/node_modules/@radix-ui/react-compose-refs/dist/index.mjs
index 7dd9172..d1b53a5 100644
--- a/node_modules/@radix-ui/react-compose-refs/dist/index.mjs
+++ b/node_modules/@radix-ui/react-compose-refs/dist/index.mjs
@@ -32,7 +32,32 @@ function composeRefs(...refs) {
};
}
function useComposedRefs(...refs) {
- return React.useCallback(composeRefs(...refs), refs);
+ const refsRef = React.useRef(refs);
+ React.useLayoutEffect(() => {
+ refsRef.current = refs;
+ });
+ return React.useCallback((node) => {
+ let hasCleanup = false;
+ const cleanups = refsRef.current.map((ref) => {
+ const cleanup = setRef(ref, node);
+ if (!hasCleanup && typeof cleanup === "function") {
+ hasCleanup = true;
+ }
+ return cleanup;
+ });
+ if (hasCleanup) {
+ return () => {
+ for (let i = 0; i < cleanups.length; i++) {
+ const cleanup = cleanups[i];
+ if (typeof cleanup === "function") {
+ cleanup();
+ } else {
+ setRef(refsRef.current[i], null);
+ }
+ }
+ };
+ }
+ }, []);
}
export {
composeRefs,

View File

@ -0,0 +1,46 @@
diff --git a/node_modules/@radix-ui/react-slot/dist/index.js b/node_modules/@radix-ui/react-slot/dist/index.js
index 3691205..3b62ea8 100644
--- a/node_modules/@radix-ui/react-slot/dist/index.js
+++ b/node_modules/@radix-ui/react-slot/dist/index.js
@@ -85,11 +85,12 @@ function createSlotClone(ownerName) {
if (isLazyComponent(children) && typeof use === "function") {
children = use(children._payload);
}
+ const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
+ const composedRef = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, childrenRef);
if (React.isValidElement(children)) {
- const childrenRef = getElementRef(children);
const props2 = mergeProps(slotProps, children.props);
if (children.type !== React.Fragment) {
- props2.ref = forwardedRef ? (0, import_react_compose_refs.composeRefs)(forwardedRef, childrenRef) : childrenRef;
+ props2.ref = forwardedRef ? composedRef : childrenRef;
}
return React.cloneElement(children, props2);
}
diff --git a/node_modules/@radix-ui/react-slot/dist/index.mjs b/node_modules/@radix-ui/react-slot/dist/index.mjs
index d7ea374..a990150 100644
--- a/node_modules/@radix-ui/react-slot/dist/index.mjs
+++ b/node_modules/@radix-ui/react-slot/dist/index.mjs
@@ -1,6 +1,6 @@
// src/slot.tsx
import * as React from "react";
-import { composeRefs } from "@radix-ui/react-compose-refs";
+import { composeRefs, useComposedRefs } from "@radix-ui/react-compose-refs";
import { Fragment as Fragment2, jsx } from "react/jsx-runtime";
var REACT_LAZY_TYPE = Symbol.for("react.lazy");
var use = React[" use ".trim().toString()];
@@ -45,11 +45,12 @@ function createSlotClone(ownerName) {
if (isLazyComponent(children) && typeof use === "function") {
children = use(children._payload);
}
+ const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
+ const composedRef = useComposedRefs(forwardedRef, childrenRef);
if (React.isValidElement(children)) {
- const childrenRef = getElementRef(children);
const props2 = mergeProps(slotProps, children.props);
if (children.type !== React.Fragment) {
- props2.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;
+ props2.ref = forwardedRef ? composedRef : childrenRef;
}
return React.cloneElement(children, props2);
}

View File

@ -0,0 +1,23 @@
diff --git a/node_modules/react-use-websocket/dist/lib/use-websocket.js b/node_modules/react-use-websocket/dist/lib/use-websocket.js
index f01db48..b30aff2 100644
--- a/node_modules/react-use-websocket/dist/lib/use-websocket.js
+++ b/node_modules/react-use-websocket/dist/lib/use-websocket.js
@@ -139,15 +139,15 @@ var useWebSocket = function (url, options, connect) {
}
protectedSetLastMessage = function (message) {
if (!expectClose_1) {
- (0, react_dom_1.flushSync)(function () { return setLastMessage(message); });
+ setLastMessage(message);
}
};
protectedSetReadyState = function (state) {
if (!expectClose_1) {
- (0, react_dom_1.flushSync)(function () { return setReadyState(function (prev) {
+ setReadyState(function (prev) {
var _a;
return (__assign(__assign({}, prev), (convertedUrl.current && (_a = {}, _a[convertedUrl.current] = state, _a))));
- }); });
+ });
}
};
if (createOrJoin_1) {

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

@ -83,7 +83,8 @@
"triggers": "Triggers",
"debug": "Debug",
"frigateplus": "Frigate+",
"maintenance": "Maintenance"
"mediaSync": "Media sync",
"regionGrid": "Region grid"
},
"dialog": {
"unsavedChanges": {
@ -1232,6 +1233,16 @@
"previews": "Previews",
"exports": "Exports",
"recordings": "Recordings"
},
"regionGrid": {
"title": "Region Grid",
"desc": "The region grid is an optimization that learns where objects of different sizes typically appear in each camera's field of view. Frigate uses this data to efficiently size detection regions. The grid is automatically built over time from tracked object data.",
"clear": "Clear region grid",
"clearConfirmTitle": "Clear Region Grid",
"clearConfirmDesc": "Clearing the region grid is not recommended unless you have recently changed your detector model size or have changed your camera's physical position and are having object tracking issues. The grid will be automatically rebuilt over time as objects are tracked. A Frigate restart is required for changes to take effect.",
"clearSuccess": "Region grid cleared successfully",
"clearError": "Failed to clear region grid",
"restartRequired": "Restart required for region grid changes to take effect"
}
},
"configForm": {
@ -1392,6 +1403,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

@ -114,10 +114,17 @@ interface PropertyElement {
content: React.ReactElement;
}
/** Shape of the props that RJSF injects into each property element. */
interface RjsfElementProps {
schema?: { type?: string | string[] };
uiSchema?: Record<string, unknown> & {
"ui:widget"?: string;
"ui:options"?: Record<string, unknown>;
};
}
function isObjectLikeElement(item: PropertyElement) {
const fieldSchema = item.content.props?.schema as
| { type?: string | string[] }
| undefined;
const fieldSchema = (item.content.props as RjsfElementProps)?.schema;
return fieldSchema?.type === "object";
}
@ -163,16 +170,21 @@ function GridLayoutObjectFieldTemplate(
// Override the properties rendering with grid layout
const isHiddenProp = (prop: (typeof properties)[number]) =>
prop.content.props.uiSchema?.["ui:widget"] === "hidden";
(prop.content.props as RjsfElementProps).uiSchema?.["ui:widget"] ===
"hidden";
const visibleProps = properties.filter((prop) => !isHiddenProp(prop));
// Separate regular and advanced properties
const advancedProps = visibleProps.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
(p) =>
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
?.advanced === true,
);
const regularProps = visibleProps.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
(p) =>
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
?.advanced !== true,
);
const hasModifiedAdvanced = advancedProps.some((prop) =>
isPathModified([...fieldPath, prop.name]),

View File

@ -448,6 +448,9 @@ export function FieldTemplate(props: FieldTemplateProps) {
);
};
const errorsProps = errors?.props as { errors?: unknown[] } | undefined;
const hasFieldErrors = !!errors && (errorsProps?.errors?.length ?? 0) > 0;
const renderStandardLabel = () => {
if (!shouldRenderStandardLabel) {
return null;
@ -459,7 +462,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
className={cn(
"text-sm font-medium",
isModified && "text-danger",
errors && errors.props?.errors?.length > 0 && "text-destructive",
hasFieldErrors && "text-destructive",
)}
>
{finalLabel}
@ -497,7 +500,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
className={cn(
"text-sm font-medium",
isModified && "text-danger",
errors && errors.props?.errors?.length > 0 && "text-destructive",
hasFieldErrors && "text-destructive",
)}
>
{finalLabel}

View File

@ -1,6 +1,7 @@
// Custom MultiSchemaFieldTemplate to handle anyOf [Type, null] fields
// Renders simple nullable types as single inputs instead of dropdowns
import type { JSX } from "react";
import {
MultiSchemaFieldTemplateProps,
StrictRJSFSchema,

View File

@ -25,6 +25,15 @@ import {
import get from "lodash/get";
import { AddPropertyButton, AdvancedCollapsible } from "../components";
/** Shape of the props that RJSF injects into each property element. */
interface RjsfElementProps {
schema?: { type?: string | string[] };
uiSchema?: Record<string, unknown> & {
"ui:widget"?: string;
"ui:options"?: Record<string, unknown>;
};
}
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const {
title,
@ -182,16 +191,21 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
uiSchema?.["ui:options"]?.disableNestedCard === true;
const isHiddenProp = (prop: (typeof properties)[number]) =>
prop.content.props.uiSchema?.["ui:widget"] === "hidden";
(prop.content.props as RjsfElementProps).uiSchema?.["ui:widget"] ===
"hidden";
const visibleProps = properties.filter((prop) => !isHiddenProp(prop));
// Check for advanced section grouping
const advancedProps = visibleProps.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
(p) =>
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
?.advanced === true,
);
const regularProps = visibleProps.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
(p) =>
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
?.advanced !== true,
);
const hasModifiedAdvanced = advancedProps.some((prop) =>
checkSubtreeModified([...fieldPath, prop.name]),
@ -333,9 +347,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const ungrouped = items.filter((item) => !grouped.has(item.name));
const isObjectLikeField = (item: (typeof properties)[number]) => {
const fieldSchema = item.content.props.schema as
| { type?: string | string[] }
| undefined;
const fieldSchema = (item.content.props as RjsfElementProps)?.schema;
return fieldSchema?.type === "object";
};

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,4 +1,5 @@
import { t } from "i18next";
import type { JSX } from "react";
import { FunctionComponent, useEffect, useMemo, useState } from "react";
interface IProp {

View File

@ -1,8 +1,8 @@
import { cn } from "@/lib/utils";
import { LogSeverity } from "@/types/log";
import { ReactNode, useMemo, useRef } from "react";
import { ReactNode, useMemo } from "react";
import { isIOS } from "react-device-detect";
import { CSSTransition } from "react-transition-group";
import { AnimatePresence, motion } from "framer-motion";
type ChipProps = {
className?: string;
@ -17,39 +17,31 @@ export default function Chip({
in: inProp = true,
onClick,
}: ChipProps) {
const nodeRef = useRef(null);
return (
<CSSTransition
in={inProp}
nodeRef={nodeRef}
timeout={500}
classNames={{
enter: "opacity-0",
enterActive: "opacity-100 transition-opacity duration-500 ease-in-out",
exit: "opacity-100",
exitActive: "opacity-0 transition-opacity duration-500 ease-in-out",
}}
unmountOnExit
>
<div
ref={nodeRef}
className={cn(
"flex items-center rounded-2xl px-2 py-1.5",
className,
!isIOS && "z-10",
)}
onClick={(e) => {
e.stopPropagation();
<AnimatePresence>
{inProp && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: "easeInOut" }}
className={cn(
"flex items-center rounded-2xl px-2 py-1.5",
className,
!isIOS && "z-10",
)}
onClick={(e) => {
e.stopPropagation();
if (onClick) {
onClick();
}
}}
>
{children}
</div>
</CSSTransition>
if (onClick) {
onClick();
}
}}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}

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

@ -55,9 +55,9 @@ export function MobilePage({
});
return (
<MobilePageContext.Provider value={{ open, onOpenChange: setOpen }}>
<MobilePageContext value={{ open, onOpenChange: setOpen }}>
{children}
</MobilePageContext.Provider>
</MobilePageContext>
);
}
@ -102,7 +102,7 @@ export function MobilePagePortal({
type MobilePageContentProps = {
children: React.ReactNode;
className?: string;
scrollerRef?: React.RefObject<HTMLDivElement>;
scrollerRef?: React.RefObject<HTMLDivElement | null>;
};
export function MobilePageContent({

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

@ -10,7 +10,7 @@ import Konva from "konva";
import { useResizeObserver } from "@/hooks/resize-observer";
type DebugDrawingLayerProps = {
containerRef: React.RefObject<HTMLDivElement>;
containerRef: React.RefObject<HTMLDivElement | null>;
cameraWidth: number;
cameraHeight: number;
};

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

@ -17,7 +17,7 @@ type ObjectPathProps = {
color?: number[];
width?: number;
pointRadius?: number;
imgRef: React.RefObject<HTMLImageElement>;
imgRef: React.RefObject<HTMLImageElement | null>;
onPointClick?: (index: number) => void;
visible?: boolean;
};

View File

@ -1,6 +1,7 @@
import useSWR from "swr";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useResizeObserver } from "@/hooks/resize-observer";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { Event } from "@/types/event";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { TrackingDetailsSequence } from "@/types/timeline";
@ -243,6 +244,8 @@ export function TrackingDetails({
}, [manualOverride, currentTime, annotationOffset]);
const containerRef = useRef<HTMLDivElement | null>(null);
const { fullscreen, toggleFullscreen, supportsFullScreen } =
useFullscreen(containerRef);
const timelineContainerRef = useRef<HTMLDivElement | null>(null);
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
const [_selectedZone, setSelectedZone] = useState("");
@ -559,14 +562,15 @@ export function TrackingDetails({
visible={true}
currentSource={videoSource}
hotKeys={false}
supportsFullscreen={false}
fullscreen={false}
supportsFullscreen={supportsFullScreen}
fullscreen={fullscreen}
frigateControls={true}
onTimeUpdate={handleTimeUpdate}
onSeekToTime={handleSeekToTime}
onUploadFrame={onUploadFrameToPlus}
onPlaying={() => setIsVideoLoading(false)}
setFullResolution={setFullResolution}
toggleFullscreen={toggleFullscreen}
isDetailMode={true}
camera={event.camera}
currentTimeOverride={currentTime}

View File

@ -22,6 +22,7 @@ import {
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
import type { JSX } from "react";
import { useRef } from "react";
type PlatformAwareDialogProps = {

View File

@ -94,7 +94,7 @@ export default function HlsVideoPlayer({
// playback
const hlsRef = useRef<Hls>();
const hlsRef = useRef<Hls>(undefined);
const [useHlsCompat, setUseHlsCompat] = useState(false);
const [loadedMetadata, setLoadedMetadata] = useState(false);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();

View File

@ -51,10 +51,10 @@ export default function WebRtcPlayer({
// camera states
const pcRef = useRef<RTCPeerConnection | undefined>();
const pcRef = useRef<RTCPeerConnection | undefined>(undefined);
const videoRef = useRef<HTMLVideoElement | null>(null);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const videoLoadTimeoutRef = useRef<NodeJS.Timeout>();
const videoLoadTimeoutRef = useRef<NodeJS.Timeout>(undefined);
const PeerConnection = useCallback(
async (media: string) => {

View File

@ -10,7 +10,7 @@ import { snapPointToLines } from "@/utils/canvasUtil";
import { usePolygonStates } from "@/hooks/use-polygon-states";
type PolygonCanvasProps = {
containerRef: RefObject<HTMLDivElement>;
containerRef: RefObject<HTMLDivElement | null>;
camera: string;
width: number;
height: number;
@ -258,7 +258,7 @@ export function PolygonCanvas({
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
if (containerRef.current && !activePolygon.isFinished) {
if (containerRef.current && activePolygon && !activePolygon.isFinished) {
containerRef.current.style.cursor = "crosshair";
}
};

View File

@ -18,7 +18,7 @@ import Konva from "konva";
import { Vector2d } from "konva/lib/types";
type PolygonDrawerProps = {
stageRef: RefObject<Konva.Stage>;
stageRef: RefObject<Konva.Stage | null>;
points: number[][];
distances: number[];
isActive: boolean;

View File

@ -329,7 +329,7 @@ export default function PolygonItem({
<div
key={index}
className="transition-background my-1.5 flex flex-row items-center justify-between rounded-lg p-1 duration-100"
className="transition-background relative my-1.5 flex flex-row items-center justify-between rounded-lg p-1 duration-100"
data-index={index}
onMouseEnter={() => setHoveredPolygonIndex(index)}
onMouseLeave={() => setHoveredPolygonIndex(null)}
@ -341,7 +341,7 @@ export default function PolygonItem({
}}
>
<div
className={`flex items-center ${
className={`flex min-w-0 items-center ${
hoveredPolygonIndex === index
? "text-primary"
: "text-primary-variant"
@ -359,7 +359,7 @@ export default function PolygonItem({
type="button"
onClick={handleToggleEnabled}
disabled={isLoading || polygon.enabled_in_config === false}
className="mr-2 cursor-pointer border-none bg-transparent p-0 transition-opacity hover:opacity-70 disabled:cursor-not-allowed disabled:opacity-50"
className="mr-2 shrink-0 cursor-pointer border-none bg-transparent p-0 transition-opacity hover:opacity-70 disabled:cursor-not-allowed disabled:opacity-50"
>
<PolygonItemIcon
className="size-5"
@ -469,7 +469,15 @@ export default function PolygonItem({
</>
)}
{!isMobile && hoveredPolygonIndex === index && (
<div className="flex flex-row items-center gap-2">
<div
className="absolute inset-y-0 right-0 flex flex-row items-center gap-2 rounded-r-lg pl-8 pr-1"
style={{
background:
polygon.color.length === 3
? `linear-gradient(to right, transparent 0%, rgba(${polygon.color[2]},${polygon.color[1]},${polygon.color[0]},0.85) 40%)`
: "linear-gradient(to right, transparent 0%, rgba(220,0,0,0.85) 40%)",
}}
>
<Tooltip>
<TooltipTrigger asChild>
<IconWrapper

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

@ -37,8 +37,8 @@ export type EventReviewTimelineProps = {
events: ReviewSegment[];
visibleTimestamps?: number[];
severityType: ReviewSeverity;
timelineRef?: RefObject<HTMLDivElement>;
contentRef: RefObject<HTMLDivElement>;
timelineRef?: RefObject<HTMLDivElement | null>;
contentRef: RefObject<HTMLDivElement | null>;
onHandlebarDraggingChange?: (isDragging: boolean) => void;
isZooming: boolean;
zoomDirection: TimelineZoomDirection;

View File

@ -28,7 +28,7 @@ type EventSegmentProps = {
minimapStartTime?: number;
minimapEndTime?: number;
severityType: ReviewSeverity;
contentRef: RefObject<HTMLDivElement>;
contentRef: RefObject<HTMLDivElement | null>;
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
dense: boolean;

View File

@ -42,8 +42,8 @@ export type MotionReviewTimelineProps = {
events: ReviewSegment[];
motion_events: MotionData[];
noRecordingRanges?: RecordingSegment[];
contentRef: RefObject<HTMLDivElement>;
timelineRef?: RefObject<HTMLDivElement>;
contentRef: RefObject<HTMLDivElement | null>;
timelineRef?: RefObject<HTMLDivElement | null>;
onHandlebarDraggingChange?: (isDragging: boolean) => void;
dense?: boolean;
isZooming: boolean;

View File

@ -20,8 +20,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
export type ReviewTimelineProps = {
timelineRef: RefObject<HTMLDivElement>;
contentRef: RefObject<HTMLDivElement>;
timelineRef: RefObject<HTMLDivElement | null>;
contentRef: RefObject<HTMLDivElement | null>;
segmentDuration: number;
timelineDuration: number;
timelineStartAligned: number;

View File

@ -14,7 +14,7 @@ import {
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
export type SummaryTimelineProps = {
reviewTimelineRef: React.RefObject<HTMLDivElement>;
reviewTimelineRef: React.RefObject<HTMLDivElement | null>;
timelineStart: number;
timelineEnd: number;
segmentDuration: number;

View File

@ -10,7 +10,7 @@ import { EventSegment } from "./EventSegment";
import { ReviewSegment, ReviewSeverity } from "@/types/review";
type VirtualizedEventSegmentsProps = {
timelineRef: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement | null>;
segments: number[];
events: ReviewSegment[];
segmentDuration: number;
@ -19,7 +19,7 @@ type VirtualizedEventSegmentsProps = {
minimapStartTime?: number;
minimapEndTime?: number;
severityType: ReviewSeverity;
contentRef: React.RefObject<HTMLDivElement>;
contentRef: React.RefObject<HTMLDivElement | null>;
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
dense: boolean;
alignStartDateToTimeline: (timestamp: number) => number;

View File

@ -10,7 +10,7 @@ import MotionSegment from "./MotionSegment";
import { ReviewSegment, MotionData } from "@/types/review";
type VirtualizedMotionSegmentsProps = {
timelineRef: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement | null>;
segments: number[];
events: ReviewSegment[];
motion_events: MotionData[];
@ -19,7 +19,7 @@ type VirtualizedMotionSegmentsProps = {
showMinimap: boolean;
minimapStartTime?: number;
minimapEndTime?: number;
contentRef: React.RefObject<HTMLDivElement>;
contentRef: React.RefObject<HTMLDivElement | null>;
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
dense: boolean;
motionOnly: boolean;

View File

@ -1,3 +1,4 @@
import type { JSX } from "react";
import { useState, useEffect, useRef } from "react";
import { Button } from "./button";
import { Calendar } from "./calendar";
@ -124,8 +125,8 @@ export function DateRangePicker({
);
// Refs to store the values of range and rangeCompare when the date picker is opened
const openedRangeRef = useRef<DateRange | undefined>();
const openedRangeCompareRef = useRef<DateRange | undefined>();
const openedRangeRef = useRef<DateRange | undefined>(undefined);
const openedRangeCompareRef = useRef<DateRange | undefined>(undefined);
const [selectedPreset, setSelectedPreset] = useState<string | undefined>(
undefined,

View File

@ -1,265 +0,0 @@
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref,
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
});
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { t } = useTranslation(["views/explore"]);
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
aria-label={t("trackingDetails.carousel.previous")}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">{t("trackingDetails.carousel.previous")}</span>
</Button>
);
});
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { t } = useTranslation(["views/explore"]);
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
aria-label={t("trackingDetails.carousel.next")}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">{t("trackingDetails.carousel.next")}</span>
</Button>
);
});
CarouselNext.displayName = "CarouselNext";
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View File

@ -33,9 +33,9 @@ const FormField = <
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<FormFieldContext value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
</FormFieldContext>
);
};
@ -77,9 +77,9 @@ const FormItem = React.forwardRef<
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<FormItemContext value={{ id }}>
<div ref={ref} className={cn("space-y-1", className)} {...props} />
</FormItemContext.Provider>
</FormItemContext>
);
});
FormItem.displayName = "FormItem";

View File

@ -1,10 +1,10 @@
import { ForwardedRef, forwardRef } from "react";
import { IconType } from "react-icons";
interface IconWrapperProps {
interface IconWrapperProps extends React.HTMLAttributes<HTMLDivElement> {
icon: IconType;
className?: string;
[key: string]: any;
disabled?: boolean;
}
const IconWrapper = forwardRef(

View File

@ -141,7 +141,7 @@ const SidebarProvider = React.forwardRef<
);
return (
<SidebarContext.Provider value={contextValue}>
<SidebarContext value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
@ -161,7 +161,7 @@ const SidebarProvider = React.forwardRef<
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
</SidebarContext>
);
},
);

View File

@ -22,9 +22,9 @@ const ToggleGroup = React.forwardRef<
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
<ToggleGroupContext value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupContext>
</ToggleGroupPrimitive.Root>
))

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

@ -102,9 +102,5 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
axios.get("/logout", { withCredentials: true });
};
return (
<AuthContext.Provider value={{ auth, login, logout }}>
{children}
</AuthContext.Provider>
);
return <AuthContext value={{ auth, login, logout }}>{children}</AuthContext>;
}

View File

@ -125,11 +125,7 @@ export function DetailStreamProvider({
isDetailMode,
};
return (
<DetailStreamContext.Provider value={value}>
{children}
</DetailStreamContext.Provider>
);
return <DetailStreamContext value={value}>{children}</DetailStreamContext>;
}
// eslint-disable-next-line react-refresh/only-export-components

View File

@ -77,9 +77,9 @@ export function LanguageProvider({
};
return (
<LanguageProviderContext.Provider {...props} value={value}>
<LanguageProviderContext {...props} value={value}>
{children}
</LanguageProviderContext.Provider>
</LanguageProviderContext>
);
}

View File

@ -1,6 +1,5 @@
import { ReactNode } from "react";
import { ThemeProvider } from "@/context/theme-provider";
import { RecoilRoot } from "recoil";
import { ApiProvider } from "@/api";
import { IconContext } from "react-icons";
import { TooltipProvider } from "@/components/ui/tooltip";
@ -15,25 +14,23 @@ type TProvidersProps = {
function providers({ children }: TProvidersProps) {
return (
<RecoilRoot>
<AuthProvider>
<ApiProvider>
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
<LanguageProvider>
<TooltipProvider>
<IconContext.Provider value={{ size: "20" }}>
<StatusBarMessagesProvider>
<StreamingSettingsProvider>
{children}
</StreamingSettingsProvider>
</StatusBarMessagesProvider>
</IconContext.Provider>
</TooltipProvider>
</LanguageProvider>
</ThemeProvider>
</ApiProvider>
</AuthProvider>
</RecoilRoot>
<AuthProvider>
<ApiProvider>
<ThemeProvider defaultTheme="system" storageKey="frigate-ui-theme">
<LanguageProvider>
<TooltipProvider>
<IconContext.Provider value={{ size: "20" }}>
<StatusBarMessagesProvider>
<StreamingSettingsProvider>
{children}
</StreamingSettingsProvider>
</StatusBarMessagesProvider>
</IconContext.Provider>
</TooltipProvider>
</LanguageProvider>
</ThemeProvider>
</ApiProvider>
</AuthProvider>
);
}

View File

@ -107,10 +107,10 @@ export function StatusBarMessagesProvider({
}, []);
return (
<StatusBarMessagesContext.Provider
<StatusBarMessagesContext
value={{ messages, addMessage, removeMessage, clearMessages }}
>
{children}
</StatusBarMessagesContext.Provider>
</StatusBarMessagesContext>
);
}

View File

@ -44,7 +44,7 @@ export function StreamingSettingsProvider({
}, [allGroupsStreamingSettings, setPersistedGroupStreamingSettings]);
return (
<StreamingSettingsContext.Provider
<StreamingSettingsContext
value={{
allGroupsStreamingSettings,
setAllGroupsStreamingSettings,
@ -52,7 +52,7 @@ export function StreamingSettingsProvider({
}}
>
{children}
</StreamingSettingsContext.Provider>
</StreamingSettingsContext>
);
}

View File

@ -124,9 +124,9 @@ export function ThemeProvider({
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
<ThemeProviderContext {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
</ThemeProviderContext>
);
}

Some files were not shown because too many files have changed in this diff Show More