mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
Merge branch 'dev' into pixel-search-clean
This commit is contained in:
commit
94d5a16be7
@ -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:
|
||||
|
||||
@ -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
176
frigate/api/debug_replay.py
Normal 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)
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)],
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
443
frigate/debug_replay.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -110,6 +110,7 @@ def ensure_torch_dependencies() -> bool:
|
||||
"pip",
|
||||
"install",
|
||||
"--break-system-packages",
|
||||
"setuptools<81",
|
||||
"torch",
|
||||
"torchvision",
|
||||
],
|
||||
|
||||
4562
web/package-lock.json
generated
4562
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
75
web/patches/@radix-ui+react-compose-refs+1.1.2.patch
Normal file
75
web/patches/@radix-ui+react-compose-refs+1.1.2.patch
Normal 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,
|
||||
46
web/patches/@radix-ui+react-slot+1.2.4.patch
Normal file
46
web/patches/@radix-ui+react-slot+1.2.4.patch
Normal 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);
|
||||
}
|
||||
23
web/patches/react-use-websocket+4.8.1.patch
Normal file
23
web/patches/react-use-websocket+4.8.1.patch
Normal 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) {
|
||||
@ -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",
|
||||
|
||||
@ -216,6 +216,10 @@
|
||||
},
|
||||
"hideObjectDetails": {
|
||||
"label": "Hide object path"
|
||||
},
|
||||
"debugReplay": {
|
||||
"label": "Debug replay",
|
||||
"aria": "View this tracked object in the debug replay view"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
54
web/public/locales/en/views/replay.json
Normal file
54
web/public/locales/en/views/replay.json
Normal 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."
|
||||
}
|
||||
}
|
||||
@ -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}}",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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="/" />} />
|
||||
|
||||
@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -4,4 +4,5 @@ export type SectionConfigOverrides = {
|
||||
base?: SectionConfig;
|
||||
global?: Partial<SectionConfig>;
|
||||
camera?: Partial<SectionConfig>;
|
||||
replay?: Partial<SectionConfig>;
|
||||
};
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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]),
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { t } from "i18next";
|
||||
import type { JSX } from "react";
|
||||
import { FunctionComponent, useEffect, useMemo, useState } from "react";
|
||||
|
||||
interface IProp {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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({
|
||||
|
||||
46
web/src/components/overlay/ActionsDropdown.tsx
Normal file
46
web/src/components/overlay/ActionsDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
240
web/src/components/overlay/CustomTimeSelector.tsx
Normal file
240
web/src/components/overlay/CustomTimeSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
|
||||
367
web/src/components/overlay/DebugReplayDialog.tsx
Normal file
367
web/src/components/overlay/DebugReplayDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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";
|
||||
}
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -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>
|
||||
))
|
||||
|
||||
|
||||
608
web/src/components/ws/WsMessageFeed.tsx
Normal file
608
web/src/components/ws/WsMessageFeed.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
433
web/src/components/ws/WsMessageRow.tsx
Normal file
433
web/src/components/ws/WsMessageRow.tsx
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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;
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -77,9 +77,9 @@ export function LanguageProvider({
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageProviderContext.Provider {...props} value={value}>
|
||||
<LanguageProviderContext {...props} value={value}>
|
||||
{children}
|
||||
</LanguageProviderContext.Provider>
|
||||
</LanguageProviderContext>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -107,10 +107,10 @@ export function StatusBarMessagesProvider({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StatusBarMessagesContext.Provider
|
||||
<StatusBarMessagesContext
|
||||
value={{ messages, addMessage, removeMessage, clearMessages }}
|
||||
>
|
||||
{children}
|
||||
</StatusBarMessagesContext.Provider>
|
||||
</StatusBarMessagesContext>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user