mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 02:29:19 +03:00
Debug replay (#22212)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* debug replay implementation * fix masks after dev rebase * fix squash merge issues * fix * fix * fix * no need to write debug replay camera to config * camera and filter button and dropdown * add filters * add ability to edit motion and object config for debug replay * add debug draw overlay to debug replay * add guard to prevent crash when camera is no longer in camera_states * fix overflow due to radix absolutely positioned elements * increase number of messages * ensure deep_merge replaces existing list values when override is true * add back button * add debug replay to explore and review menus * clean up * clean up * update instructions to prevent exposing exception info * fix typing * refactor output logic * refactor with helper function * move init to function for consistency
This commit is contained in:
parent
5e7d426768
commit
95956a690b
16
.github/copilot-instructions.md
vendored
16
.github/copilot-instructions.md
vendored
@ -324,6 +324,12 @@ try:
|
|||||||
value = await sensor.read()
|
value = await sensor.read()
|
||||||
except Exception: # ❌ Too broad
|
except Exception: # ❌ Too broad
|
||||||
logger.error("Failed")
|
logger.error("Failed")
|
||||||
|
|
||||||
|
# Returning exceptions in JSON responses
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse(
|
||||||
|
content={"success": False, "message": str(e)},
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### ✅ Use These Instead
|
### ✅ Use These Instead
|
||||||
@ -353,6 +359,16 @@ try:
|
|||||||
value = await sensor.read()
|
value = await sensor.read()
|
||||||
except SensorException as err: # ✅ Specific
|
except SensorException as err: # ✅ Specific
|
||||||
logger.exception("Failed to read sensor")
|
logger.exception("Failed to read sensor")
|
||||||
|
|
||||||
|
# Safe error responses
|
||||||
|
except ValueError:
|
||||||
|
logger.exception("Invalid parameters for API request")
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": "Invalid request parameters",
|
||||||
|
},
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project-Specific Conventions
|
## Project-Specific Conventions
|
||||||
|
|||||||
@ -75,4 +75,4 @@ Many providers also have a public facing chat interface for their models. Downlo
|
|||||||
|
|
||||||
- OpenAI - [ChatGPT](https://chatgpt.com)
|
- OpenAI - [ChatGPT](https://chatgpt.com)
|
||||||
- Gemini - [Google AI Studio](https://aistudio.google.com)
|
- Gemini - [Google AI Studio](https://aistudio.google.com)
|
||||||
- Ollama - [Open WebUI](https://docs.openwebui.com/)
|
- Ollama - [Open WebUI](https://docs.openwebui.com/)
|
||||||
|
|||||||
@ -49,12 +49,13 @@ from frigate.stats.prometheus import get_metrics, update_metrics
|
|||||||
from frigate.types import JobStatusTypesEnum
|
from frigate.types import JobStatusTypesEnum
|
||||||
from frigate.util.builtin import (
|
from frigate.util.builtin import (
|
||||||
clean_camera_user_pass,
|
clean_camera_user_pass,
|
||||||
|
deep_merge,
|
||||||
flatten_config_data,
|
flatten_config_data,
|
||||||
load_labels,
|
load_labels,
|
||||||
process_config_query_string,
|
process_config_query_string,
|
||||||
update_yaml_file_bulk,
|
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.schema import get_config_schema
|
||||||
from frigate.util.services import (
|
from frigate.util.services import (
|
||||||
get_nvidia_driver_info,
|
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"]))])
|
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
|
||||||
def config_set(request: Request, body: AppConfigSetBody):
|
def config_set(request: Request, body: AppConfigSetBody):
|
||||||
config_file = find_config_file()
|
config_file = find_config_file()
|
||||||
|
|
||||||
|
if body.skip_save:
|
||||||
|
return _config_set_in_memory(request, body)
|
||||||
|
|
||||||
lock = FileLock(f"{config_file}.lock", timeout=5)
|
lock = FileLock(f"{config_file}.lock", timeout=5)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
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
|
requires_restart: int = 1
|
||||||
update_topic: str | None = None
|
update_topic: str | None = None
|
||||||
config_data: Optional[Dict[str, Any]] = None
|
config_data: Optional[Dict[str, Any]] = None
|
||||||
|
skip_save: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AppPutPasswordBody(BaseModel):
|
class AppPutPasswordBody(BaseModel):
|
||||||
|
|||||||
@ -18,6 +18,7 @@ from frigate.api import (
|
|||||||
camera,
|
camera,
|
||||||
chat,
|
chat,
|
||||||
classification,
|
classification,
|
||||||
|
debug_replay,
|
||||||
event,
|
event,
|
||||||
export,
|
export,
|
||||||
media,
|
media,
|
||||||
@ -32,6 +33,7 @@ from frigate.comms.event_metadata_updater import (
|
|||||||
)
|
)
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||||
|
from frigate.debug_replay import DebugReplayManager
|
||||||
from frigate.embeddings import EmbeddingsContext
|
from frigate.embeddings import EmbeddingsContext
|
||||||
from frigate.genai import GenAIClientManager
|
from frigate.genai import GenAIClientManager
|
||||||
from frigate.ptz.onvif import OnvifController
|
from frigate.ptz.onvif import OnvifController
|
||||||
@ -65,6 +67,7 @@ def create_fastapi_app(
|
|||||||
stats_emitter: StatsEmitter,
|
stats_emitter: StatsEmitter,
|
||||||
event_metadata_updater: EventMetadataPublisher,
|
event_metadata_updater: EventMetadataPublisher,
|
||||||
config_publisher: CameraConfigUpdatePublisher,
|
config_publisher: CameraConfigUpdatePublisher,
|
||||||
|
replay_manager: DebugReplayManager,
|
||||||
enforce_default_admin: bool = True,
|
enforce_default_admin: bool = True,
|
||||||
):
|
):
|
||||||
logger.info("Starting FastAPI app")
|
logger.info("Starting FastAPI app")
|
||||||
@ -133,6 +136,7 @@ def create_fastapi_app(
|
|||||||
app.include_router(event.router)
|
app.include_router(event.router)
|
||||||
app.include_router(media.router)
|
app.include_router(media.router)
|
||||||
app.include_router(record.router)
|
app.include_router(record.router)
|
||||||
|
app.include_router(debug_replay.router)
|
||||||
# App Properties
|
# App Properties
|
||||||
app.frigate_config = frigate_config
|
app.frigate_config = frigate_config
|
||||||
app.genai_manager = GenAIClientManager(frigate_config)
|
app.genai_manager = GenAIClientManager(frigate_config)
|
||||||
@ -144,6 +148,7 @@ def create_fastapi_app(
|
|||||||
app.stats_emitter = stats_emitter
|
app.stats_emitter = stats_emitter
|
||||||
app.event_metadata_updater = event_metadata_updater
|
app.event_metadata_updater = event_metadata_updater
|
||||||
app.config_publisher = config_publisher
|
app.config_publisher = config_publisher
|
||||||
|
app.replay_manager = replay_manager
|
||||||
|
|
||||||
if frigate_config.auth.enabled:
|
if frigate_config.auth.enabled:
|
||||||
secret = get_jwt_secret()
|
secret = get_jwt_secret()
|
||||||
|
|||||||
@ -43,6 +43,10 @@ from frigate.const import (
|
|||||||
)
|
)
|
||||||
from frigate.data_processing.types import DataProcessorMetrics
|
from frigate.data_processing.types import DataProcessorMetrics
|
||||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||||
|
from frigate.debug_replay import (
|
||||||
|
DebugReplayManager,
|
||||||
|
cleanup_replay_cameras,
|
||||||
|
)
|
||||||
from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
|
from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
|
||||||
from frigate.events.audio import AudioProcessor
|
from frigate.events.audio import AudioProcessor
|
||||||
from frigate.events.cleanup import EventCleanup
|
from frigate.events.cleanup import EventCleanup
|
||||||
@ -139,6 +143,9 @@ class FrigateApp:
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"Skipping directory: {d}")
|
logger.debug(f"Skipping directory: {d}")
|
||||||
|
|
||||||
|
def init_debug_replay_manager(self) -> None:
|
||||||
|
self.replay_manager = DebugReplayManager()
|
||||||
|
|
||||||
def init_camera_metrics(self) -> None:
|
def init_camera_metrics(self) -> None:
|
||||||
# create camera_metrics
|
# create camera_metrics
|
||||||
for camera_name in self.config.cameras.keys():
|
for camera_name in self.config.cameras.keys():
|
||||||
@ -531,6 +538,7 @@ class FrigateApp:
|
|||||||
set_file_limit()
|
set_file_limit()
|
||||||
|
|
||||||
# Start frigate services.
|
# Start frigate services.
|
||||||
|
self.init_debug_replay_manager()
|
||||||
self.init_camera_metrics()
|
self.init_camera_metrics()
|
||||||
self.init_queues()
|
self.init_queues()
|
||||||
self.init_database()
|
self.init_database()
|
||||||
@ -541,6 +549,10 @@ class FrigateApp:
|
|||||||
self.init_embeddings_manager()
|
self.init_embeddings_manager()
|
||||||
self.bind_database()
|
self.bind_database()
|
||||||
self.check_db_data_migrations()
|
self.check_db_data_migrations()
|
||||||
|
|
||||||
|
# Clean up any stale replay camera artifacts (filesystem + DB)
|
||||||
|
cleanup_replay_cameras()
|
||||||
|
|
||||||
self.init_inter_process_communicator()
|
self.init_inter_process_communicator()
|
||||||
self.start_detectors()
|
self.start_detectors()
|
||||||
self.init_dispatcher()
|
self.init_dispatcher()
|
||||||
@ -572,6 +584,7 @@ class FrigateApp:
|
|||||||
self.stats_emitter,
|
self.stats_emitter,
|
||||||
self.event_metadata_updater,
|
self.event_metadata_updater,
|
||||||
self.inter_config_updater,
|
self.inter_config_updater,
|
||||||
|
self.replay_manager,
|
||||||
),
|
),
|
||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
port=5001,
|
port=5001,
|
||||||
@ -637,6 +650,7 @@ class FrigateApp:
|
|||||||
self.record_cleanup.join()
|
self.record_cleanup.join()
|
||||||
self.stats_emitter.join()
|
self.stats_emitter.join()
|
||||||
self.frigate_watchdog.join()
|
self.frigate_watchdog.join()
|
||||||
|
self.camera_maintainer.join()
|
||||||
self.db.stop()
|
self.db.stop()
|
||||||
|
|
||||||
# Save embeddings stats to disk
|
# Save embeddings stats to disk
|
||||||
|
|||||||
@ -57,6 +57,9 @@ class CameraActivityManager:
|
|||||||
all_objects: list[dict[str, Any]] = []
|
all_objects: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for camera in new_activity.keys():
|
for camera in new_activity.keys():
|
||||||
|
if camera not in self.config.cameras:
|
||||||
|
continue
|
||||||
|
|
||||||
# handle cameras that were added dynamically
|
# handle cameras that were added dynamically
|
||||||
if camera not in self.camera_all_object_counts:
|
if camera not in self.camera_all_object_counts:
|
||||||
self.__init_camera(self.config.cameras[camera])
|
self.__init_camera(self.config.cameras[camera])
|
||||||
@ -124,7 +127,11 @@ class CameraActivityManager:
|
|||||||
any_changed = False
|
any_changed = False
|
||||||
|
|
||||||
# run through each object and check what topics need to be updated
|
# 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:
|
if label in self.config.model.non_logo_attributes:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -174,6 +181,9 @@ class AudioActivityManager:
|
|||||||
now = datetime.datetime.now().timestamp()
|
now = datetime.datetime.now().timestamp()
|
||||||
|
|
||||||
for camera in new_activity.keys():
|
for camera in new_activity.keys():
|
||||||
|
if camera not in self.config.cameras:
|
||||||
|
continue
|
||||||
|
|
||||||
# handle cameras that were added dynamically
|
# handle cameras that were added dynamically
|
||||||
if camera not in self.current_audio_detections:
|
if camera not in self.current_audio_detections:
|
||||||
self.__init_camera(self.config.cameras[camera])
|
self.__init_camera(self.config.cameras[camera])
|
||||||
@ -193,7 +203,11 @@ class AudioActivityManager:
|
|||||||
def compare_audio_activity(
|
def compare_audio_activity(
|
||||||
self, camera: str, new_detections: list[tuple[str, float]], now: float
|
self, camera: str, new_detections: list[tuple[str, float]], now: float
|
||||||
) -> None:
|
) -> 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]
|
current = self.current_audio_detections[camera]
|
||||||
|
|
||||||
any_changed = False
|
any_changed = False
|
||||||
|
|||||||
@ -55,8 +55,20 @@ class CameraMaintainer(threading.Thread):
|
|||||||
self.shm_count = self.__calculate_shm_frame_count()
|
self.shm_count = self.__calculate_shm_frame_count()
|
||||||
self.camera_processes: dict[str, mp.Process] = {}
|
self.camera_processes: dict[str, mp.Process] = {}
|
||||||
self.capture_processes: dict[str, mp.Process] = {}
|
self.capture_processes: dict[str, mp.Process] = {}
|
||||||
|
self.camera_stop_events: dict[str, MpEvent] = {}
|
||||||
self.metrics_manager = metrics_manager
|
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:
|
def __init_historical_regions(self) -> None:
|
||||||
# delete region grids for removed or renamed cameras
|
# delete region grids for removed or renamed cameras
|
||||||
cameras = list(self.config.cameras.keys())
|
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}")
|
logger.info(f"Camera processor not started for disabled camera {name}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
camera_stop_event = self.__ensure_camera_stop_event(name)
|
||||||
|
|
||||||
if runtime:
|
if runtime:
|
||||||
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
|
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
|
||||||
self.ptz_metrics[name] = PTZMetrics(autotracker_enabled=False)
|
self.ptz_metrics[name] = PTZMetrics(autotracker_enabled=False)
|
||||||
@ -135,7 +149,7 @@ class CameraMaintainer(threading.Thread):
|
|||||||
self.camera_metrics[name],
|
self.camera_metrics[name],
|
||||||
self.ptz_metrics[name],
|
self.ptz_metrics[name],
|
||||||
self.region_grids[name],
|
self.region_grids[name],
|
||||||
self.stop_event,
|
camera_stop_event,
|
||||||
self.config.logger,
|
self.config.logger,
|
||||||
)
|
)
|
||||||
self.camera_processes[config.name] = camera_process
|
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}")
|
logger.info(f"Capture process not started for disabled camera {name}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
camera_stop_event = self.__ensure_camera_stop_event(name)
|
||||||
|
|
||||||
# pre-create shms
|
# pre-create shms
|
||||||
count = 10 if runtime else self.shm_count
|
count = 10 if runtime else self.shm_count
|
||||||
for i in range(count):
|
for i in range(count):
|
||||||
@ -160,7 +176,7 @@ class CameraMaintainer(threading.Thread):
|
|||||||
config,
|
config,
|
||||||
count,
|
count,
|
||||||
self.camera_metrics[name],
|
self.camera_metrics[name],
|
||||||
self.stop_event,
|
camera_stop_event,
|
||||||
self.config.logger,
|
self.config.logger,
|
||||||
)
|
)
|
||||||
capture_process.daemon = True
|
capture_process.daemon = True
|
||||||
@ -170,18 +186,36 @@ class CameraMaintainer(threading.Thread):
|
|||||||
logger.info(f"Capture process started for {name}: {capture_process.pid}")
|
logger.info(f"Capture process started for {name}: {capture_process.pid}")
|
||||||
|
|
||||||
def __stop_camera_capture_process(self, camera: str) -> None:
|
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:
|
if capture_process is not None:
|
||||||
logger.info(f"Waiting for capture process for {camera} to stop")
|
logger.info(f"Waiting for capture process for {camera} to stop")
|
||||||
capture_process.terminate()
|
camera_stop_event = self.camera_stop_events.get(camera)
|
||||||
capture_process.join()
|
|
||||||
|
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:
|
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:
|
if camera_process is not None:
|
||||||
logger.info(f"Waiting for process for {camera} to stop")
|
logger.info(f"Waiting for process for {camera} to stop")
|
||||||
camera_process.terminate()
|
camera_stop_event = self.camera_stop_events.get(camera)
|
||||||
camera_process.join()
|
|
||||||
|
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}")
|
logger.info(f"Closing frame queue for {camera}")
|
||||||
empty_and_close_queue(self.camera_metrics[camera].frame_queue)
|
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():
|
for update_type, updated_cameras in updates.items():
|
||||||
if update_type == CameraConfigUpdateEnum.add.name:
|
if update_type == CameraConfigUpdateEnum.add.name:
|
||||||
for camera in updated_cameras:
|
for camera in updated_cameras:
|
||||||
|
if (
|
||||||
|
camera in self.camera_processes
|
||||||
|
or camera in self.capture_processes
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
self.__start_camera_processor(
|
self.__start_camera_processor(
|
||||||
camera,
|
camera,
|
||||||
self.update_subscriber.camera_configs[camera],
|
self.update_subscriber.camera_configs[camera],
|
||||||
@ -210,15 +250,22 @@ class CameraMaintainer(threading.Thread):
|
|||||||
runtime=True,
|
runtime=True,
|
||||||
)
|
)
|
||||||
elif update_type == CameraConfigUpdateEnum.remove.name:
|
elif update_type == CameraConfigUpdateEnum.remove.name:
|
||||||
self.__stop_camera_capture_process(camera)
|
for camera in updated_cameras:
|
||||||
self.__stop_camera_process(camera)
|
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
|
# 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)
|
self.__stop_camera_capture_process(camera)
|
||||||
|
|
||||||
# ensure the camera processors are done
|
# 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.__stop_camera_process(camera)
|
||||||
|
|
||||||
self.update_subscriber.stop()
|
self.update_subscriber.stop()
|
||||||
|
|||||||
@ -26,8 +26,8 @@ class ConfigPublisher:
|
|||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.stop_event.set()
|
self.stop_event.set()
|
||||||
self.socket.close()
|
self.socket.close(linger=0)
|
||||||
self.context.destroy()
|
self.context.destroy(linger=0)
|
||||||
|
|
||||||
|
|
||||||
class ConfigSubscriber:
|
class ConfigSubscriber:
|
||||||
@ -55,5 +55,5 @@ class ConfigSubscriber:
|
|||||||
return (None, None)
|
return (None, None)
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.socket.close()
|
self.socket.close(linger=0)
|
||||||
self.context.destroy()
|
self.context.destroy(linger=0)
|
||||||
|
|||||||
@ -110,6 +110,9 @@ class Dispatcher:
|
|||||||
payload: str,
|
payload: str,
|
||||||
sub_command: str | None = None,
|
sub_command: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if camera_name not in self.config.cameras:
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if command_type == "set":
|
if command_type == "set":
|
||||||
if sub_command:
|
if sub_command:
|
||||||
@ -131,6 +134,9 @@ class Dispatcher:
|
|||||||
|
|
||||||
def handle_request_region_grid() -> Any:
|
def handle_request_region_grid() -> Any:
|
||||||
camera = payload
|
camera = payload
|
||||||
|
if camera not in self.config.cameras:
|
||||||
|
return None
|
||||||
|
|
||||||
grid = get_camera_regions_grid(
|
grid = get_camera_regions_grid(
|
||||||
camera,
|
camera,
|
||||||
self.config.cameras[camera].detect,
|
self.config.cameras[camera].detect,
|
||||||
@ -243,7 +249,11 @@ class Dispatcher:
|
|||||||
self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()))
|
self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()))
|
||||||
|
|
||||||
def handle_on_connect() -> None:
|
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()
|
audio_detections = self.audio_activity.current_audio_detections.copy()
|
||||||
cameras_with_status = camera_status.keys()
|
cameras_with_status = camera_status.keys()
|
||||||
|
|
||||||
@ -346,7 +356,8 @@ class Dispatcher:
|
|||||||
# example /cam_name/notifications/suspend payload=duration
|
# example /cam_name/notifications/suspend payload=duration
|
||||||
camera_name = parts[-3]
|
camera_name = parts[-3]
|
||||||
command = parts[-2]
|
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:
|
except IndexError:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Received invalid {topic.split('/')[-1]} command: {topic}"
|
f"Received invalid {topic.split('/')[-1]} command: {topic}"
|
||||||
|
|||||||
@ -61,8 +61,8 @@ class InterProcessCommunicator(Communicator):
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.stop_event.set()
|
self.stop_event.set()
|
||||||
self.reader_thread.join()
|
self.reader_thread.join()
|
||||||
self.socket.close()
|
self.socket.close(linger=0)
|
||||||
self.context.destroy()
|
self.context.destroy(linger=0)
|
||||||
|
|
||||||
|
|
||||||
class InterProcessRequestor:
|
class InterProcessRequestor:
|
||||||
@ -82,5 +82,5 @@ class InterProcessRequestor:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.socket.close()
|
self.socket.close(linger=0)
|
||||||
self.context.destroy()
|
self.context.destroy(linger=0)
|
||||||
|
|||||||
@ -43,7 +43,7 @@ class ZmqProxy:
|
|||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
# destroying the context will tell the proxy to stop
|
# destroying the context will tell the proxy to stop
|
||||||
self.context.destroy()
|
self.context.destroy(linger=0)
|
||||||
self.runner.join()
|
self.runner.join()
|
||||||
|
|
||||||
|
|
||||||
@ -66,8 +66,8 @@ class Publisher(Generic[T]):
|
|||||||
self.socket.send_string(f"{self.topic}{sub_topic} {json.dumps(payload)}")
|
self.socket.send_string(f"{self.topic}{sub_topic} {json.dumps(payload)}")
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.socket.close()
|
self.socket.close(linger=0)
|
||||||
self.context.destroy()
|
self.context.destroy(linger=0)
|
||||||
|
|
||||||
|
|
||||||
class Subscriber(Generic[T]):
|
class Subscriber(Generic[T]):
|
||||||
@ -96,8 +96,8 @@ class Subscriber(Generic[T]):
|
|||||||
return self._return_object("", None)
|
return self._return_object("", None)
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.socket.close()
|
self.socket.close(linger=0)
|
||||||
self.context.destroy()
|
self.context.destroy(linger=0)
|
||||||
|
|
||||||
def _return_object(self, topic: str, payload: T | None) -> T | None:
|
def _return_object(self, topic: str, payload: T | None) -> T | None:
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@ -80,8 +80,8 @@ class CameraConfigUpdateSubscriber:
|
|||||||
self.camera_configs[camera] = updated_config
|
self.camera_configs[camera] = updated_config
|
||||||
return
|
return
|
||||||
elif update_type == CameraConfigUpdateEnum.remove:
|
elif update_type == CameraConfigUpdateEnum.remove:
|
||||||
self.config.cameras.pop(camera)
|
self.config.cameras.pop(camera, None)
|
||||||
self.camera_configs.pop(camera)
|
self.camera_configs.pop(camera, None)
|
||||||
return
|
return
|
||||||
|
|
||||||
config = self.camera_configs.get(camera)
|
config = self.camera_configs.get(camera)
|
||||||
|
|||||||
@ -14,6 +14,8 @@ RECORD_DIR = f"{BASE_DIR}/recordings"
|
|||||||
TRIGGER_DIR = f"{CLIPS_DIR}/triggers"
|
TRIGGER_DIR = f"{CLIPS_DIR}/triggers"
|
||||||
BIRDSEYE_PIPE = "/tmp/cache/birdseye"
|
BIRDSEYE_PIPE = "/tmp/cache/birdseye"
|
||||||
CACHE_DIR = "/tmp/cache"
|
CACHE_DIR = "/tmp/cache"
|
||||||
|
REPLAY_CAMERA_PREFIX = "_replay_"
|
||||||
|
REPLAY_DIR = os.path.join(CACHE_DIR, "replay")
|
||||||
PLUS_ENV_VAR = "PLUS_API_KEY"
|
PLUS_ENV_VAR = "PLUS_API_KEY"
|
||||||
PLUS_API_HOST = "https://api.frigate.video"
|
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:
|
if self.config.semantic_search.enabled:
|
||||||
self.embeddings.update_stats()
|
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
|
# no need to process updated objects if no processors are active
|
||||||
if len(self.realtime_processors) == 0 and len(self.post_processors) == 0:
|
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:
|
if not camera or camera not in self.config.cameras:
|
||||||
return
|
return
|
||||||
|
|
||||||
camera_config = self.config.cameras[camera]
|
camera_config = self.config.cameras.get(camera)
|
||||||
|
if camera_config is None:
|
||||||
|
return
|
||||||
|
|
||||||
dedicated_lpr_enabled = (
|
dedicated_lpr_enabled = (
|
||||||
camera_config.type == CameraTypeEnum.lpr
|
camera_config.type == CameraTypeEnum.lpr
|
||||||
and "license_plate" not in camera_config.objects.track
|
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.comms.events_updater import EventEndPublisher, EventUpdateSubscriber
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.config.classification import ObjectClassificationType
|
from frigate.config.classification import ObjectClassificationType
|
||||||
|
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||||
from frigate.models import Event
|
from frigate.models import Event
|
||||||
from frigate.util.builtin import to_relative_box
|
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):
|
if should_update_db(self.events_in_process[event_data["id"]], event_data):
|
||||||
updated_db = True
|
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
|
width = camera_config.detect.width
|
||||||
height = camera_config.detect.height
|
height = camera_config.detect.height
|
||||||
first_detector = list(self.config.detectors.values())[0]
|
first_detector = list(self.config.detectors.values())[0]
|
||||||
@ -283,6 +286,10 @@ class EventProcessor(threading.Thread):
|
|||||||
def handle_external_detection(
|
def handle_external_detection(
|
||||||
self, event_type: EventStateEnum, event_data: Event
|
self, event_type: EventStateEnum, event_data: Event
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# Skip replay cameras
|
||||||
|
if event_data.get("camera", "").startswith(REPLAY_CAMERA_PREFIX):
|
||||||
|
return
|
||||||
|
|
||||||
if event_type == EventStateEnum.start:
|
if event_type == EventStateEnum.start:
|
||||||
event = {
|
event = {
|
||||||
Event.id: event_data["id"],
|
Event.id: event_data["id"],
|
||||||
|
|||||||
@ -420,7 +420,8 @@ class BirdsEyeFrameManager:
|
|||||||
[
|
[
|
||||||
cam
|
cam
|
||||||
for cam, cam_data in self.cameras.items()
|
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_in_config
|
||||||
and self.config.cameras[cam].enabled
|
and self.config.cameras[cam].enabled
|
||||||
and cam_data["last_active_frame"] > 0
|
and cam_data["last_active_frame"] > 0
|
||||||
@ -723,8 +724,11 @@ class BirdsEyeFrameManager:
|
|||||||
Update birdseye for a specific camera with new frame data.
|
Update birdseye for a specific camera with new frame data.
|
||||||
Returns (frame_changed, layout_changed) to indicate if the frame or layout changed.
|
Returns (frame_changed, layout_changed) to indicate if the frame or layout changed.
|
||||||
"""
|
"""
|
||||||
# don't process if birdseye is disabled for this camera
|
# don't process if camera was removed or birdseye is disabled
|
||||||
camera_config = self.config.cameras[camera]
|
camera_config = self.config.cameras.get(camera)
|
||||||
|
if camera_config is None:
|
||||||
|
return False, False
|
||||||
|
|
||||||
force_update = False
|
force_update = False
|
||||||
|
|
||||||
# disabling birdseye is a little tricky
|
# disabling birdseye is a little tricky
|
||||||
|
|||||||
@ -22,7 +22,12 @@ from frigate.config.camera.updater import (
|
|||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdateSubscriber,
|
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.birdseye import Birdseye
|
||||||
from frigate.output.camera import JsmpegCamera
|
from frigate.output.camera import JsmpegCamera
|
||||||
from frigate.output.preview import PreviewRecorder
|
from frigate.output.preview import PreviewRecorder
|
||||||
@ -79,6 +84,32 @@ class OutputProcess(FrigateProcess):
|
|||||||
)
|
)
|
||||||
self.config = config
|
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:
|
def run(self) -> None:
|
||||||
self.pre_run_setup(self.config.logger)
|
self.pre_run_setup(self.config.logger)
|
||||||
|
|
||||||
@ -118,14 +149,17 @@ class OutputProcess(FrigateProcess):
|
|||||||
move_preview_frames("cache")
|
move_preview_frames("cache")
|
||||||
|
|
||||||
for camera, cam_config in self.config.cameras.items():
|
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
|
continue
|
||||||
|
|
||||||
jsmpeg_cameras[camera] = JsmpegCamera(
|
self.add_camera(
|
||||||
cam_config, self.stop_event, websocket_server
|
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:
|
if self.config.birdseye.enabled:
|
||||||
birdseye = Birdseye(self.config, self.stop_event, websocket_server)
|
birdseye = Birdseye(self.config, self.stop_event, websocket_server)
|
||||||
@ -138,19 +172,15 @@ class OutputProcess(FrigateProcess):
|
|||||||
|
|
||||||
if CameraConfigUpdateEnum.add in updates:
|
if CameraConfigUpdateEnum.add in updates:
|
||||||
for camera in updates["add"]:
|
for camera in updates["add"]:
|
||||||
jsmpeg_cameras[camera] = JsmpegCamera(
|
if not self.is_debug_replay_camera(camera):
|
||||||
self.config.cameras[camera], self.stop_event, websocket_server
|
self.add_camera(
|
||||||
)
|
camera,
|
||||||
preview_recorders[camera] = PreviewRecorder(
|
websocket_server,
|
||||||
self.config.cameras[camera]
|
jsmpeg_cameras,
|
||||||
)
|
preview_recorders,
|
||||||
preview_write_times[camera] = 0
|
preview_write_times,
|
||||||
|
birdseye,
|
||||||
if (
|
)
|
||||||
self.config.birdseye.enabled
|
|
||||||
and self.config.cameras[camera].birdseye.enabled
|
|
||||||
):
|
|
||||||
birdseye.add_camera(camera)
|
|
||||||
|
|
||||||
(topic, data) = detection_subscriber.check_for_update(timeout=1)
|
(topic, data) = detection_subscriber.check_for_update(timeout=1)
|
||||||
now = datetime.datetime.now().timestamp()
|
now = datetime.datetime.now().timestamp()
|
||||||
@ -174,7 +204,11 @@ class OutputProcess(FrigateProcess):
|
|||||||
_,
|
_,
|
||||||
) = data
|
) = 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
|
continue
|
||||||
|
|
||||||
frame = frame_manager.get(
|
frame = frame_manager.get(
|
||||||
|
|||||||
@ -287,11 +287,12 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# publish most recently available recording time and None if disabled
|
# publish most recently available recording time and None if disabled
|
||||||
|
camera_cfg = self.config.cameras.get(camera)
|
||||||
self.recordings_publisher.publish(
|
self.recordings_publisher.publish(
|
||||||
(
|
(
|
||||||
camera,
|
camera,
|
||||||
recordings[0]["start_time"].timestamp()
|
recordings[0]["start_time"].timestamp()
|
||||||
if self.config.cameras[camera].record.enabled
|
if camera_cfg and camera_cfg.record.enabled
|
||||||
else None,
|
else None,
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
@ -315,9 +316,8 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
) -> Optional[Recordings]:
|
) -> Optional[Recordings]:
|
||||||
cache_path: str = recording["cache_path"]
|
cache_path: str = recording["cache_path"]
|
||||||
start_time: datetime.datetime = recording["start_time"]
|
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 (
|
if (
|
||||||
camera not in self.config.cameras
|
camera not in self.config.cameras
|
||||||
or not self.config.cameras[camera].record.enabled
|
or not self.config.cameras[camera].record.enabled
|
||||||
|
|||||||
@ -652,6 +652,9 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
if camera not in self.indefinite_events:
|
if camera not in self.indefinite_events:
|
||||||
self.indefinite_events[camera] = {}
|
self.indefinite_events[camera] = {}
|
||||||
|
|
||||||
|
if camera not in self.config.cameras:
|
||||||
|
continue
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not self.config.cameras[camera].enabled
|
not self.config.cameras[camera].enabled
|
||||||
or not self.config.cameras[camera].record.enabled
|
or not self.config.cameras[camera].record.enabled
|
||||||
|
|||||||
@ -340,6 +340,9 @@ def stats_snapshot(
|
|||||||
|
|
||||||
stats["cameras"] = {}
|
stats["cameras"] = {}
|
||||||
for name, camera_stats in camera_metrics.items():
|
for name, camera_stats in camera_metrics.items():
|
||||||
|
if name not in config.cameras:
|
||||||
|
continue
|
||||||
|
|
||||||
total_camera_fps += camera_stats.camera_fps.value
|
total_camera_fps += camera_stats.camera_fps.value
|
||||||
total_process_fps += camera_stats.process_fps.value
|
total_process_fps += camera_stats.process_fps.value
|
||||||
total_skipped_fps += camera_stats.skipped_fps.value
|
total_skipped_fps += camera_stats.skipped_fps.value
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
from peewee import SQL, fn
|
from peewee import SQL, fn
|
||||||
|
|
||||||
from frigate.config import FrigateConfig
|
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.models import Event, Recordings
|
||||||
from frigate.util.builtin import clear_and_unlink
|
from frigate.util.builtin import clear_and_unlink
|
||||||
|
|
||||||
@ -32,6 +32,10 @@ class StorageMaintainer(threading.Thread):
|
|||||||
def calculate_camera_bandwidth(self) -> None:
|
def calculate_camera_bandwidth(self) -> None:
|
||||||
"""Calculate an average MB/hr for each camera."""
|
"""Calculate an average MB/hr for each camera."""
|
||||||
for camera in self.config.cameras.keys():
|
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
|
# cameras with < 50 segments should be refreshed to keep size accurate
|
||||||
# when few segments are available
|
# when few segments are available
|
||||||
if self.camera_storage_stats.get(camera, {}).get("needs_refresh", True):
|
if self.camera_storage_stats.get(camera, {}).get("needs_refresh", True):
|
||||||
@ -77,6 +81,10 @@ class StorageMaintainer(threading.Thread):
|
|||||||
usages: dict[str, dict] = {}
|
usages: dict[str, dict] = {}
|
||||||
|
|
||||||
for camera in self.config.cameras.keys():
|
for camera in self.config.cameras.keys():
|
||||||
|
# Skip replay cameras
|
||||||
|
if camera.startswith(REPLAY_CAMERA_PREFIX):
|
||||||
|
continue
|
||||||
|
|
||||||
camera_storage = (
|
camera_storage = (
|
||||||
Recordings.select(fn.SUM(Recordings.segment_size))
|
Recordings.select(fn.SUM(Recordings.segment_size))
|
||||||
.where(Recordings.camera == camera, Recordings.segment_size != 0)
|
.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.api.fastapi_app import create_fastapi_app
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import BASE_DIR, CACHE_DIR
|
from frigate.const import BASE_DIR, CACHE_DIR
|
||||||
|
from frigate.debug_replay import DebugReplayManager
|
||||||
from frigate.models import Event, Recordings, ReviewSegment
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
from frigate.review.types import SeverityEnum
|
from frigate.review.types import SeverityEnum
|
||||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||||
@ -141,6 +142,7 @@ class BaseTestHttp(unittest.TestCase):
|
|||||||
stats,
|
stats,
|
||||||
event_metadata_publisher,
|
event_metadata_publisher,
|
||||||
None,
|
None,
|
||||||
|
DebugReplayManager(),
|
||||||
enforce_default_admin=False,
|
enforce_default_admin=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -22,3 +22,32 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
response = client.get("/stats")
|
response = client.get("/stats")
|
||||||
response_json = response.json()
|
response_json = response.json()
|
||||||
assert response_json == self.test_stats
|
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)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert "dog" in frigate_config.cameras["back"].objects.track
|
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):
|
def test_override_birdseye(self):
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
|
|||||||
@ -86,7 +86,9 @@ class TimelineProcessor(threading.Thread):
|
|||||||
event_data: dict[Any, Any],
|
event_data: dict[Any, Any],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Handle object detection."""
|
"""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"]
|
event_id = event_data["id"]
|
||||||
|
|
||||||
# Base timeline entry data that all entries will share
|
# Base timeline entry data that all entries will share
|
||||||
|
|||||||
@ -690,9 +690,13 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
self.create_camera_state(camera)
|
self.create_camera_state(camera)
|
||||||
elif "remove" in updated_topics:
|
elif "remove" in updated_topics:
|
||||||
for camera in updated_topics["remove"]:
|
for camera in updated_topics["remove"]:
|
||||||
camera_state = self.camera_states[camera]
|
removed_camera_state = self.camera_states[camera]
|
||||||
camera_state.shutdown()
|
removed_camera_state.shutdown()
|
||||||
self.camera_states.pop(camera)
|
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
|
# manage camera disabled state
|
||||||
for camera, config in self.config.cameras.items():
|
for camera, config in self.config.cameras.items():
|
||||||
@ -700,6 +704,10 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
current_enabled = config.enabled
|
current_enabled = config.enabled
|
||||||
|
camera_state = self.camera_states.get(camera)
|
||||||
|
if camera_state is None:
|
||||||
|
continue
|
||||||
|
|
||||||
camera_state = self.camera_states[camera]
|
camera_state = self.camera_states[camera]
|
||||||
|
|
||||||
if camera_state.prev_enabled and not current_enabled:
|
if camera_state.prev_enabled and not current_enabled:
|
||||||
@ -752,7 +760,11 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
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")
|
logger.debug(f"Camera {camera} disabled, skipping update")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ from frigate.config import (
|
|||||||
SnapshotsConfig,
|
SnapshotsConfig,
|
||||||
UIConfig,
|
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.detectors.detector_config import ModelConfig
|
||||||
from frigate.review.types import SeverityEnum
|
from frigate.review.types import SeverityEnum
|
||||||
from frigate.util.builtin import sanitize_float
|
from frigate.util.builtin import sanitize_float
|
||||||
@ -621,6 +621,9 @@ class TrackedObject:
|
|||||||
if not self.camera_config.name:
|
if not self.camera_config.name:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self.camera_config.name.startswith(REPLAY_CAMERA_PREFIX):
|
||||||
|
return
|
||||||
|
|
||||||
directory = os.path.join(THUMB_DIR, self.camera_config.name)
|
directory = os.path.join(THUMB_DIR, self.camera_config.name)
|
||||||
|
|
||||||
if not os.path.exists(directory):
|
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 dct1: First dict to merge
|
||||||
:param dct2: Second 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
|
:return: The merge dictionary
|
||||||
"""
|
"""
|
||||||
merged = copy.deepcopy(dct1)
|
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):
|
elif isinstance(v1, list) and isinstance(v2, list):
|
||||||
if merge_lists:
|
if merge_lists:
|
||||||
merged[k] = v1 + v2
|
merged[k] = v1 + v2
|
||||||
|
elif override:
|
||||||
|
merged[k] = copy.deepcopy(v2)
|
||||||
else:
|
else:
|
||||||
if override:
|
if override:
|
||||||
merged[k] = copy.deepcopy(v2)
|
merged[k] = copy.deepcopy(v2)
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from typing import Any, Optional, Union
|
|||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
from frigate.const import CONFIG_DIR, EXPORT_DIR
|
from frigate.const import CONFIG_DIR, EXPORT_DIR
|
||||||
|
from frigate.util.builtin import deep_merge
|
||||||
from frigate.util.services import get_video_properties
|
from frigate.util.services import get_video_properties
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -688,3 +689,78 @@ class StreamInfoRetriever:
|
|||||||
info = asyncio.run(get_video_properties(ffmpeg, path))
|
info = asyncio.run(get_video_properties(ffmpeg, path))
|
||||||
self.stream_cache[path] = info
|
self.stream_cache[path] = info
|
||||||
return 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
|
||||||
|
|||||||
@ -117,6 +117,7 @@
|
|||||||
"button": {
|
"button": {
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
|
"applying": "Applying…",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"undo": "Undo",
|
"undo": "Undo",
|
||||||
"done": "Done",
|
"done": "Done",
|
||||||
@ -252,6 +253,7 @@
|
|||||||
"review": "Review",
|
"review": "Review",
|
||||||
"explore": "Explore",
|
"explore": "Explore",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
|
"actions": "Actions",
|
||||||
"uiPlayground": "UI Playground",
|
"uiPlayground": "UI Playground",
|
||||||
"faceLibrary": "Face Library",
|
"faceLibrary": "Face Library",
|
||||||
"classification": "Classification",
|
"classification": "Classification",
|
||||||
|
|||||||
@ -216,6 +216,10 @@
|
|||||||
},
|
},
|
||||||
"hideObjectDetails": {
|
"hideObjectDetails": {
|
||||||
"label": "Hide object path"
|
"label": "Hide object path"
|
||||||
|
},
|
||||||
|
"debugReplay": {
|
||||||
|
"label": "Debug replay",
|
||||||
|
"aria": "View this tracked object in the debug replay view"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dialog": {
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1392,6 +1392,7 @@
|
|||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"success": "Settings saved successfully",
|
"success": "Settings saved successfully",
|
||||||
|
"applied": "Settings applied successfully",
|
||||||
"successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.",
|
"successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.",
|
||||||
"error": "Failed to save settings",
|
"error": "Failed to save settings",
|
||||||
"validationError": "Validation failed: {{message}}",
|
"validationError": "Validation failed: {{message}}",
|
||||||
|
|||||||
@ -7,12 +7,39 @@
|
|||||||
"logs": {
|
"logs": {
|
||||||
"frigate": "Frigate Logs - Frigate",
|
"frigate": "Frigate Logs - Frigate",
|
||||||
"go2rtc": "Go2RTC Logs - Frigate",
|
"go2rtc": "Go2RTC Logs - Frigate",
|
||||||
"nginx": "Nginx Logs - Frigate"
|
"nginx": "Nginx Logs - Frigate",
|
||||||
|
"websocket": "Messages Logs - Frigate"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "System",
|
"title": "System",
|
||||||
"metrics": "System metrics",
|
"metrics": "System metrics",
|
||||||
"logs": {
|
"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": {
|
"download": {
|
||||||
"label": "Download Logs"
|
"label": "Download Logs"
|
||||||
},
|
},
|
||||||
@ -189,7 +216,8 @@
|
|||||||
"cameraIsOffline": "{{camera}} is offline",
|
"cameraIsOffline": "{{camera}} is offline",
|
||||||
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
|
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
|
||||||
"detectIsVerySlow": "{{detect}} is very 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": {
|
"enrichments": {
|
||||||
"title": "Enrichments",
|
"title": "Enrichments",
|
||||||
|
|||||||
@ -30,6 +30,7 @@ const Classification = lazy(() => import("@/pages/ClassificationModel"));
|
|||||||
const Chat = lazy(() => import("@/pages/Chat"));
|
const Chat = lazy(() => import("@/pages/Chat"));
|
||||||
const Logs = lazy(() => import("@/pages/Logs"));
|
const Logs = lazy(() => import("@/pages/Logs"));
|
||||||
const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
|
const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
|
||||||
|
const Replay = lazy(() => import("@/pages/Replay"));
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
@ -108,7 +109,8 @@ function DefaultAppView() {
|
|||||||
<Route path="/faces" element={<FaceLibrary />} />
|
<Route path="/faces" element={<FaceLibrary />} />
|
||||||
<Route path="/classification" element={<Classification />} />
|
<Route path="/classification" element={<Classification />} />
|
||||||
<Route path="/chat" element={<Chat />} />
|
<Route path="/chat" element={<Chat />} />
|
||||||
<Route path="/playground" element={<UIPlayground />} />
|
<Route path="/playground" element={<UIPlayground />} />{" "}
|
||||||
|
<Route path="/replay" element={<Replay />} />{" "}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/unauthorized" element={<AccessDenied />} />
|
<Route path="/unauthorized" element={<AccessDenied />} />
|
||||||
<Route path="*" element={<Redirect to="/" />} />
|
<Route path="*" element={<Redirect to="/" />} />
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { baseUrl } from "./baseUrl";
|
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 useWebSocket, { ReadyState } from "react-use-websocket";
|
||||||
import {
|
import {
|
||||||
EmbeddingsReindexProgressType,
|
EmbeddingsReindexProgressType,
|
||||||
@ -17,6 +17,13 @@ import { FrigateStats } from "@/types/stats";
|
|||||||
import { createContainer } from "react-tracked";
|
import { createContainer } from "react-tracked";
|
||||||
import useDeepMemo from "@/hooks/use-deep-memo";
|
import useDeepMemo from "@/hooks/use-deep-memo";
|
||||||
|
|
||||||
|
export type WsFeedMessage = {
|
||||||
|
topic: string;
|
||||||
|
payload: unknown;
|
||||||
|
timestamp: number;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Update = {
|
type Update = {
|
||||||
topic: string;
|
topic: string;
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
@ -29,6 +36,9 @@ type WsState = {
|
|||||||
|
|
||||||
type useValueReturn = [WsState, (update: Update) => void];
|
type useValueReturn = [WsState, (update: Update) => void];
|
||||||
|
|
||||||
|
const wsMessageSubscribers = new Set<(msg: WsFeedMessage) => void>();
|
||||||
|
let wsMessageIdCounter = 0;
|
||||||
|
|
||||||
function useValue(): useValueReturn {
|
function useValue(): useValueReturn {
|
||||||
const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`;
|
const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`;
|
||||||
|
|
||||||
@ -43,8 +53,13 @@ function useValue(): useValueReturn {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cameraActivity: { [key: string]: FrigateCameraState } =
|
let cameraActivity: { [key: string]: Partial<FrigateCameraState> };
|
||||||
JSON.parse(activityValue);
|
|
||||||
|
try {
|
||||||
|
cameraActivity = JSON.parse(activityValue);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(cameraActivity).length === 0) {
|
if (Object.keys(cameraActivity).length === 0) {
|
||||||
return;
|
return;
|
||||||
@ -53,6 +68,12 @@ function useValue(): useValueReturn {
|
|||||||
const cameraStates: WsState = {};
|
const cameraStates: WsState = {};
|
||||||
|
|
||||||
Object.entries(cameraActivity).forEach(([name, state]) => {
|
Object.entries(cameraActivity).forEach(([name, state]) => {
|
||||||
|
const cameraConfig = state?.config;
|
||||||
|
|
||||||
|
if (!cameraConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
record,
|
record,
|
||||||
detect,
|
detect,
|
||||||
@ -67,7 +88,7 @@ function useValue(): useValueReturn {
|
|||||||
detections,
|
detections,
|
||||||
object_descriptions,
|
object_descriptions,
|
||||||
review_descriptions,
|
review_descriptions,
|
||||||
} = state["config"];
|
} = cameraConfig;
|
||||||
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
|
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
|
||||||
cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF";
|
cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF";
|
||||||
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
|
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
|
||||||
@ -115,6 +136,17 @@ function useValue(): useValueReturn {
|
|||||||
...prevState,
|
...prevState,
|
||||||
[data.topic]: data.payload,
|
[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: () => {
|
onOpen: () => {
|
||||||
@ -740,3 +772,16 @@ export function useJobStatus(
|
|||||||
|
|
||||||
return { payload: currentJob as Job | null };
|
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 containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const imgRef = useRef<HTMLImageElement | 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 { payload: enabledState } = useEnabledState(camera);
|
||||||
const enabled = enabledState ? enabledState === "ON" : true;
|
const enabled = enabledState ? enabledState === "ON" : true;
|
||||||
|
|
||||||
@ -34,15 +35,15 @@ export default function CameraImage({
|
|||||||
useResizeObserver(containerRef);
|
useResizeObserver(containerRef);
|
||||||
|
|
||||||
const requestHeight = useMemo(() => {
|
const requestHeight = useMemo(() => {
|
||||||
if (!config || containerHeight == 0) {
|
if (!cameraConfig || containerHeight == 0) {
|
||||||
return 360;
|
return 360;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.min(
|
return Math.min(
|
||||||
config.cameras[camera].detect.height,
|
cameraConfig.detect.height,
|
||||||
Math.round(containerHeight * (isDesktop ? 1.1 : 1.25)),
|
Math.round(containerHeight * (isDesktop ? 1.1 : 1.25)),
|
||||||
);
|
);
|
||||||
}, [config, camera, containerHeight]);
|
}, [cameraConfig, containerHeight]);
|
||||||
|
|
||||||
const [isPortraitImage, setIsPortraitImage] = useState(false);
|
const [isPortraitImage, setIsPortraitImage] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@ -44,6 +44,30 @@ const motion: SectionConfigOverrides = {
|
|||||||
camera: {
|
camera: {
|
||||||
restartRequired: ["frame_height"],
|
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;
|
export default motion;
|
||||||
|
|||||||
@ -99,6 +99,28 @@ const objects: SectionConfigOverrides = {
|
|||||||
camera: {
|
camera: {
|
||||||
restartRequired: [],
|
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;
|
export default objects;
|
||||||
|
|||||||
@ -4,4 +4,5 @@ export type SectionConfigOverrides = {
|
|||||||
base?: SectionConfig;
|
base?: SectionConfig;
|
||||||
global?: Partial<SectionConfig>;
|
global?: Partial<SectionConfig>;
|
||||||
camera?: Partial<SectionConfig>;
|
camera?: Partial<SectionConfig>;
|
||||||
|
replay?: Partial<SectionConfig>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -95,9 +95,9 @@ export interface SectionConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BaseSectionProps {
|
export interface BaseSectionProps {
|
||||||
/** Whether this is at global or camera level */
|
/** Whether this is at global, camera, or replay level */
|
||||||
level: "global" | "camera";
|
level: "global" | "camera" | "replay";
|
||||||
/** Camera name (required if level is "camera") */
|
/** Camera name (required if level is "camera" or "replay") */
|
||||||
cameraName?: string;
|
cameraName?: string;
|
||||||
/** Whether to show override indicator badge */
|
/** Whether to show override indicator badge */
|
||||||
showOverrideIndicator?: boolean;
|
showOverrideIndicator?: boolean;
|
||||||
@ -117,6 +117,10 @@ export interface BaseSectionProps {
|
|||||||
defaultCollapsed?: boolean;
|
defaultCollapsed?: boolean;
|
||||||
/** Whether to show the section title (default: false for global, true for camera) */
|
/** Whether to show the section title (default: false for global, true for camera) */
|
||||||
showTitle?: boolean;
|
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 */
|
/** Callback when section status changes */
|
||||||
onStatusChange?: (status: {
|
onStatusChange?: (status: {
|
||||||
hasChanges: boolean;
|
hasChanges: boolean;
|
||||||
@ -156,12 +160,16 @@ export function ConfigSection({
|
|||||||
collapsible = false,
|
collapsible = false,
|
||||||
defaultCollapsed = true,
|
defaultCollapsed = true,
|
||||||
showTitle,
|
showTitle,
|
||||||
|
skipSave = false,
|
||||||
|
noStickyButtons = false,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
pendingDataBySection,
|
pendingDataBySection,
|
||||||
onPendingDataChange,
|
onPendingDataChange,
|
||||||
}: ConfigSectionProps) {
|
}: ConfigSectionProps) {
|
||||||
|
// For replay level, treat as camera-level config access
|
||||||
|
const effectiveLevel = level === "replay" ? "camera" : level;
|
||||||
const { t, i18n } = useTranslation([
|
const { t, i18n } = useTranslation([
|
||||||
level === "camera" ? "config/cameras" : "config/global",
|
effectiveLevel === "camera" ? "config/cameras" : "config/global",
|
||||||
"config/cameras",
|
"config/cameras",
|
||||||
"views/settings",
|
"views/settings",
|
||||||
"common",
|
"common",
|
||||||
@ -174,10 +182,10 @@ export function ConfigSection({
|
|||||||
// Create a key for this section's pending data
|
// Create a key for this section's pending data
|
||||||
const pendingDataKey = useMemo(
|
const pendingDataKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
level === "camera" && cameraName
|
effectiveLevel === "camera" && cameraName
|
||||||
? `${cameraName}::${sectionPath}`
|
? `${cameraName}::${sectionPath}`
|
||||||
: sectionPath,
|
: sectionPath,
|
||||||
[level, cameraName, sectionPath],
|
[effectiveLevel, cameraName, sectionPath],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use pending data from parent if available, otherwise use local state
|
// 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 lastPendingDataKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const updateTopic =
|
const updateTopic =
|
||||||
level === "camera" && cameraName
|
effectiveLevel === "camera" && cameraName
|
||||||
? cameraUpdateTopicMap[sectionPath]
|
? cameraUpdateTopicMap[sectionPath]
|
||||||
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
|
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
|
||||||
: undefined
|
: undefined
|
||||||
: `config/${sectionPath}`;
|
: `config/${sectionPath}`;
|
||||||
// Default: show title for camera level (since it might be collapsible), hide for global
|
// 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
|
// Fetch config
|
||||||
const { data: config, mutate: refreshConfig } =
|
const { data: config, mutate: refreshConfig } =
|
||||||
useSWR<FrigateConfig>("config");
|
useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
// Get section schema using cached hook
|
// 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
|
// Apply special case handling for sections with problematic schema defaults
|
||||||
const modifiedSchema = useMemo(
|
const modifiedSchema = useMemo(
|
||||||
@ -247,7 +255,7 @@ export function ConfigSection({
|
|||||||
// Get override status
|
// Get override status
|
||||||
const { isOverridden, globalValue, cameraValue } = useConfigOverride({
|
const { isOverridden, globalValue, cameraValue } = useConfigOverride({
|
||||||
config,
|
config,
|
||||||
cameraName: level === "camera" ? cameraName : undefined,
|
cameraName: effectiveLevel === "camera" ? cameraName : undefined,
|
||||||
sectionPath,
|
sectionPath,
|
||||||
compareFields: sectionConfig.overrideFields,
|
compareFields: sectionConfig.overrideFields,
|
||||||
});
|
});
|
||||||
@ -256,12 +264,12 @@ export function ConfigSection({
|
|||||||
const rawSectionValue = useMemo(() => {
|
const rawSectionValue = useMemo(() => {
|
||||||
if (!config) return undefined;
|
if (!config) return undefined;
|
||||||
|
|
||||||
if (level === "camera" && cameraName) {
|
if (effectiveLevel === "camera" && cameraName) {
|
||||||
return get(config.cameras?.[cameraName], sectionPath);
|
return get(config.cameras?.[cameraName], sectionPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
return get(config, sectionPath);
|
return get(config, sectionPath);
|
||||||
}, [config, level, cameraName, sectionPath]);
|
}, [config, cameraName, sectionPath, effectiveLevel]);
|
||||||
|
|
||||||
const rawFormData = useMemo(() => {
|
const rawFormData = useMemo(() => {
|
||||||
if (!config) return {};
|
if (!config) return {};
|
||||||
@ -328,9 +336,10 @@ export function ConfigSection({
|
|||||||
[rawFormData, sanitizeSectionData],
|
[rawFormData, sanitizeSectionData],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clear pendingData whenever formData changes (e.g., from server refresh)
|
// Clear pendingData whenever the section/camera key changes (e.g., switching
|
||||||
// This prevents RJSF's initial onChange call from being treated as a user edit
|
// cameras) or when there is no pending data yet (initialization).
|
||||||
// Only clear if pendingData is managed locally (not by parent)
|
// 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(() => {
|
useEffect(() => {
|
||||||
const pendingKeyChanged = lastPendingDataKeyRef.current !== pendingDataKey;
|
const pendingKeyChanged = lastPendingDataKeyRef.current !== pendingDataKey;
|
||||||
|
|
||||||
@ -339,15 +348,16 @@ export function ConfigSection({
|
|||||||
isInitializingRef.current = true;
|
isInitializingRef.current = true;
|
||||||
setPendingOverrides(undefined);
|
setPendingOverrides(undefined);
|
||||||
setDirtyOverrides(undefined);
|
setDirtyOverrides(undefined);
|
||||||
|
|
||||||
|
// Reset local pending data when switching sections/cameras
|
||||||
|
if (onPendingDataChange === undefined) {
|
||||||
|
setPendingData(null);
|
||||||
|
}
|
||||||
} else if (!pendingData) {
|
} else if (!pendingData) {
|
||||||
isInitializingRef.current = true;
|
isInitializingRef.current = true;
|
||||||
setPendingOverrides(undefined);
|
setPendingOverrides(undefined);
|
||||||
setDirtyOverrides(undefined);
|
setDirtyOverrides(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onPendingDataChange === undefined) {
|
|
||||||
setPendingData(null);
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
onPendingDataChange,
|
onPendingDataChange,
|
||||||
pendingData,
|
pendingData,
|
||||||
@ -484,7 +494,7 @@ export function ConfigSection({
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const basePath =
|
const basePath =
|
||||||
level === "camera" && cameraName
|
effectiveLevel === "camera" && cameraName
|
||||||
? `cameras.${cameraName}.${sectionPath}`
|
? `cameras.${cameraName}.${sectionPath}`
|
||||||
: sectionPath;
|
: sectionPath;
|
||||||
const rawData = sanitizeSectionData(rawFormData);
|
const rawData = sanitizeSectionData(rawFormData);
|
||||||
@ -495,7 +505,7 @@ export function ConfigSection({
|
|||||||
);
|
);
|
||||||
const sanitizedOverrides = sanitizeOverridesForSection(
|
const sanitizedOverrides = sanitizeOverridesForSection(
|
||||||
sectionPath,
|
sectionPath,
|
||||||
level,
|
effectiveLevel,
|
||||||
overrides,
|
overrides,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -508,16 +518,26 @@ export function ConfigSection({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsRestart = requiresRestartForOverrides(sanitizedOverrides);
|
const needsRestart = skipSave
|
||||||
|
? false
|
||||||
|
: requiresRestartForOverrides(sanitizedOverrides);
|
||||||
|
|
||||||
const configData = buildConfigDataForPath(basePath, sanitizedOverrides);
|
const configData = buildConfigDataForPath(basePath, sanitizedOverrides);
|
||||||
await axios.put("config/set", {
|
await axios.put("config/set", {
|
||||||
requires_restart: needsRestart ? 1 : 0,
|
requires_restart: needsRestart ? 1 : 0,
|
||||||
update_topic: updateTopic,
|
update_topic: updateTopic,
|
||||||
config_data: configData,
|
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(
|
statusBar?.addMessage(
|
||||||
"config_restart_required",
|
"config_restart_required",
|
||||||
t("configForm.restartRequiredFooter", {
|
t("configForm.restartRequiredFooter", {
|
||||||
@ -596,7 +616,7 @@ export function ConfigSection({
|
|||||||
}, [
|
}, [
|
||||||
sectionPath,
|
sectionPath,
|
||||||
pendingData,
|
pendingData,
|
||||||
level,
|
effectiveLevel,
|
||||||
cameraName,
|
cameraName,
|
||||||
t,
|
t,
|
||||||
refreshConfig,
|
refreshConfig,
|
||||||
@ -608,15 +628,16 @@ export function ConfigSection({
|
|||||||
updateTopic,
|
updateTopic,
|
||||||
setPendingData,
|
setPendingData,
|
||||||
requiresRestartForOverrides,
|
requiresRestartForOverrides,
|
||||||
|
skipSave,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
|
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
|
||||||
const handleResetToGlobal = useCallback(async () => {
|
const handleResetToGlobal = useCallback(async () => {
|
||||||
if (level === "camera" && !cameraName) return;
|
if (effectiveLevel === "camera" && !cameraName) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const basePath =
|
const basePath =
|
||||||
level === "camera" && cameraName
|
effectiveLevel === "camera" && cameraName
|
||||||
? `cameras.${cameraName}.${sectionPath}`
|
? `cameras.${cameraName}.${sectionPath}`
|
||||||
: sectionPath;
|
: sectionPath;
|
||||||
|
|
||||||
@ -632,7 +653,7 @@ export function ConfigSection({
|
|||||||
t("toast.resetSuccess", {
|
t("toast.resetSuccess", {
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
defaultValue:
|
defaultValue:
|
||||||
level === "global"
|
effectiveLevel === "global"
|
||||||
? "Reset to defaults"
|
? "Reset to defaults"
|
||||||
: "Reset to global defaults",
|
: "Reset to global defaults",
|
||||||
}),
|
}),
|
||||||
@ -651,7 +672,7 @@ export function ConfigSection({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
sectionPath,
|
sectionPath,
|
||||||
level,
|
effectiveLevel,
|
||||||
cameraName,
|
cameraName,
|
||||||
requiresRestart,
|
requiresRestart,
|
||||||
t,
|
t,
|
||||||
@ -661,8 +682,8 @@ export function ConfigSection({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const sectionValidation = useMemo(
|
const sectionValidation = useMemo(
|
||||||
() => getSectionValidation({ sectionPath, level, t }),
|
() => getSectionValidation({ sectionPath, level: effectiveLevel, t }),
|
||||||
[sectionPath, level, t],
|
[sectionPath, effectiveLevel, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const customValidate = useMemo(() => {
|
const customValidate = useMemo(() => {
|
||||||
@ -733,7 +754,7 @@ export function ConfigSection({
|
|||||||
// nested under the section name (e.g., `audio.label`). For global-level
|
// nested under the section name (e.g., `audio.label`). For global-level
|
||||||
// sections, keys are nested under the section name in `config/global`.
|
// sections, keys are nested under the section name in `config/global`.
|
||||||
const configNamespace =
|
const configNamespace =
|
||||||
level === "camera" ? "config/cameras" : "config/global";
|
effectiveLevel === "camera" ? "config/cameras" : "config/global";
|
||||||
const title = t(`${sectionPath}.label`, {
|
const title = t(`${sectionPath}.label`, {
|
||||||
ns: configNamespace,
|
ns: configNamespace,
|
||||||
defaultValue: defaultTitle,
|
defaultValue: defaultTitle,
|
||||||
@ -769,7 +790,7 @@ export function ConfigSection({
|
|||||||
i18nNamespace={configNamespace}
|
i18nNamespace={configNamespace}
|
||||||
customValidate={customValidate}
|
customValidate={customValidate}
|
||||||
formContext={{
|
formContext={{
|
||||||
level,
|
level: effectiveLevel,
|
||||||
cameraName,
|
cameraName,
|
||||||
globalValue,
|
globalValue,
|
||||||
cameraValue,
|
cameraValue,
|
||||||
@ -784,7 +805,7 @@ export function ConfigSection({
|
|||||||
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
||||||
// For widgets that need access to full camera config (e.g., zone names)
|
// For widgets that need access to full camera config (e.g., zone names)
|
||||||
fullCameraConfig:
|
fullCameraConfig:
|
||||||
level === "camera" && cameraName
|
effectiveLevel === "camera" && cameraName
|
||||||
? config?.cameras?.[cameraName]
|
? config?.cameras?.[cameraName]
|
||||||
: undefined,
|
: undefined,
|
||||||
fullConfig: config,
|
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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center gap-4 pt-2 md:flex-row",
|
"flex flex-col items-center gap-4 pt-2 md:flex-row",
|
||||||
@ -822,15 +848,17 @@ export function ConfigSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full items-center gap-2 md:w-auto">
|
<div className="flex w-full items-center gap-2 md:w-auto">
|
||||||
{((level === "camera" && isOverridden) || level === "global") &&
|
{((effectiveLevel === "camera" && isOverridden) ||
|
||||||
!hasChanges && (
|
effectiveLevel === "global") &&
|
||||||
|
!hasChanges &&
|
||||||
|
!skipSave && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsResetDialogOpen(true)}
|
onClick={() => setIsResetDialogOpen(true)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={isSaving || disabled}
|
disabled={isSaving || disabled}
|
||||||
className="flex flex-1 gap-2"
|
className="flex flex-1 gap-2"
|
||||||
>
|
>
|
||||||
{level === "global"
|
{effectiveLevel === "global"
|
||||||
? t("button.resetToDefault", {
|
? t("button.resetToDefault", {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
defaultValue: "Reset to Default",
|
defaultValue: "Reset to Default",
|
||||||
@ -862,11 +890,18 @@ export function ConfigSection({
|
|||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
<ActivityIndicator className="h-4 w-4" />
|
<ActivityIndicator className="h-4 w-4" />
|
||||||
{t("button.saving", {
|
{skipSave
|
||||||
ns: "common",
|
? t("button.applying", {
|
||||||
defaultValue: "Saving...",
|
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" })
|
t("button.save", { ns: "common", defaultValue: "Save" })
|
||||||
)}
|
)}
|
||||||
@ -898,7 +933,7 @@ export function ConfigSection({
|
|||||||
setIsResetDialogOpen(false);
|
setIsResetDialogOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{level === "global"
|
{effectiveLevel === "global"
|
||||||
? t("button.resetToDefault", { ns: "common" })
|
? t("button.resetToDefault", { ns: "common" })
|
||||||
: t("button.resetToGlobal", { ns: "common" })}
|
: t("button.resetToGlobal", { ns: "common" })}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
@ -923,7 +958,7 @@ export function ConfigSection({
|
|||||||
)}
|
)}
|
||||||
<Heading as="h4">{title}</Heading>
|
<Heading as="h4">{title}</Heading>
|
||||||
{showOverrideIndicator &&
|
{showOverrideIndicator &&
|
||||||
level === "camera" &&
|
effectiveLevel === "camera" &&
|
||||||
isOverridden && (
|
isOverridden && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{t("button.overridden", {
|
{t("button.overridden", {
|
||||||
@ -967,7 +1002,7 @@ export function ConfigSection({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Heading as="h4">{title}</Heading>
|
<Heading as="h4">{title}</Heading>
|
||||||
{showOverrideIndicator &&
|
{showOverrideIndicator &&
|
||||||
level === "camera" &&
|
effectiveLevel === "camera" &&
|
||||||
isOverridden && (
|
isOverridden && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
@ -93,7 +93,7 @@ export function AudioLabelSwitchesWidget(props: WidgetProps) {
|
|||||||
getDisplayLabel: getAudioLabelDisplayName,
|
getDisplayLabel: getAudioLabelDisplayName,
|
||||||
i18nKey: "audioLabels",
|
i18nKey: "audioLabels",
|
||||||
listClassName:
|
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,
|
enableSearch: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -94,7 +94,7 @@ export function ObjectLabelSwitchesWidget(props: WidgetProps) {
|
|||||||
getDisplayLabel: getObjectLabelDisplayName,
|
getDisplayLabel: getObjectLabelDisplayName,
|
||||||
i18nKey: "objectLabels",
|
i18nKey: "objectLabels",
|
||||||
listClassName:
|
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,
|
getEntities: getZoneNames,
|
||||||
getDisplayLabel: getZoneDisplayName,
|
getDisplayLabel: getZoneDisplayName,
|
||||||
i18nKey: "zoneNames",
|
i18nKey: "zoneNames",
|
||||||
listClassName: "max-h-64 overflow-y-auto scrollbar-container",
|
listClassName: "relative max-h-64 overflow-y-auto scrollbar-container",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { useState, ReactNode } from "react";
|
import { useState, ReactNode, useCallback } from "react";
|
||||||
import { SearchResult } from "@/types/search";
|
import { SearchResult } from "@/types/search";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { FiMoreVertical } from "react-icons/fi";
|
import { FiMoreVertical } from "react-icons/fi";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@ -32,6 +32,7 @@ import useSWR from "swr";
|
|||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import BlurredIconButton from "../button/BlurredIconButton";
|
import BlurredIconButton from "../button/BlurredIconButton";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
type SearchResultActionsProps = {
|
type SearchResultActionsProps = {
|
||||||
searchResult: SearchResult;
|
searchResult: SearchResult;
|
||||||
@ -52,8 +53,10 @@ export default function SearchResultActions({
|
|||||||
isContextMenu = false,
|
isContextMenu = false,
|
||||||
children,
|
children,
|
||||||
}: SearchResultActionsProps) {
|
}: SearchResultActionsProps) {
|
||||||
const { t } = useTranslation(["views/explore"]);
|
const { t } = useTranslation(["views/explore", "views/replay", "common"]);
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
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 MenuItem = isContextMenu ? ContextMenuItem : DropdownMenuItem;
|
||||||
|
|
||||||
const menuItems = (
|
const menuItems = (
|
||||||
@ -149,6 +205,20 @@ export default function SearchResultActions({
|
|||||||
<span>{t("itemMenu.addTrigger.label")}</span>
|
<span>{t("itemMenu.addTrigger.label")}</span>
|
||||||
</MenuItem>
|
</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 && (
|
{isAdmin && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
aria-label={t("itemMenu.deleteTrackedObject.label")}
|
aria-label={t("itemMenu.deleteTrackedObject.label")}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -12,16 +12,12 @@ import { Label } from "../ui/label";
|
|||||||
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { ExportMode } from "@/types/filter";
|
import { ExportMode } from "@/types/filter";
|
||||||
import { FaArrowDown, FaArrowRight, FaCalendarAlt } from "react-icons/fa";
|
import { FaArrowDown } from "react-icons/fa";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import { TimeRange } from "@/types/timeline";
|
import { TimeRange } from "@/types/timeline";
|
||||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
|
||||||
import { TimezoneAwareCalendar } from "./ReviewActivityCalendar";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -30,15 +26,15 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "../ui/select";
|
} 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 { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||||
import SaveExportOverlay from "./SaveExportOverlay";
|
import SaveExportOverlay from "./SaveExportOverlay";
|
||||||
import { getUTCOffset } from "@/utils/dateUtil";
|
|
||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { GenericVideoPlayer } from "../player/GenericVideoPlayer";
|
import { GenericVideoPlayer } from "../player/GenericVideoPlayer";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ExportCase } from "@/types/export";
|
import { ExportCase } from "@/types/export";
|
||||||
|
import { CustomTimeSelector } from "./CustomTimeSelector";
|
||||||
|
|
||||||
const EXPORT_OPTIONS = [
|
const EXPORT_OPTIONS = [
|
||||||
"1",
|
"1",
|
||||||
@ -167,31 +163,33 @@ export default function ExportDialog({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trigger asChild>
|
{!isDesktop && (
|
||||||
<Button
|
<Trigger asChild>
|
||||||
className="flex items-center gap-2"
|
<Button
|
||||||
aria-label={t("menu.export", { ns: "common" })}
|
className="flex items-center gap-2"
|
||||||
size="sm"
|
aria-label={t("menu.export", { ns: "common" })}
|
||||||
onClick={() => {
|
size="sm"
|
||||||
const now = new Date(latestTime * 1000);
|
onClick={() => {
|
||||||
let start = 0;
|
const now = new Date(latestTime * 1000);
|
||||||
now.setHours(now.getHours() - 1);
|
let start = 0;
|
||||||
start = now.getTime() / 1000;
|
now.setHours(now.getHours() - 1);
|
||||||
setRange({
|
start = now.getTime() / 1000;
|
||||||
before: latestTime,
|
setRange({
|
||||||
after: start,
|
before: latestTime,
|
||||||
});
|
after: start,
|
||||||
setMode("select");
|
});
|
||||||
}}
|
setMode("select");
|
||||||
>
|
}}
|
||||||
<FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" />
|
>
|
||||||
{isDesktop && (
|
<FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" />
|
||||||
<div className="text-primary">
|
{isDesktop && (
|
||||||
{t("menu.export", { ns: "common" })}
|
<div className="text-primary">
|
||||||
</div>
|
{t("menu.export", { ns: "common" })}
|
||||||
)}
|
</div>
|
||||||
</Button>
|
)}
|
||||||
</Trigger>
|
</Button>
|
||||||
|
</Trigger>
|
||||||
|
)}
|
||||||
<Content
|
<Content
|
||||||
className={
|
className={
|
||||||
isDesktop
|
isDesktop
|
||||||
@ -332,6 +330,8 @@ export function ExportContent({
|
|||||||
latestTime={latestTime}
|
latestTime={latestTime}
|
||||||
range={range}
|
range={range}
|
||||||
setRange={setRange}
|
setRange={setRange}
|
||||||
|
startLabel={t("export.time.start.title")}
|
||||||
|
endLabel={t("export.time.end.title")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Input
|
<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 = {
|
type ExportPreviewDialogProps = {
|
||||||
camera: string;
|
camera: string;
|
||||||
range?: TimeRange;
|
range?: TimeRange;
|
||||||
|
|||||||
@ -2,8 +2,13 @@ import { useCallback, useState } from "react";
|
|||||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
|
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
|
||||||
|
import { LuBug } from "react-icons/lu";
|
||||||
import { TimeRange } from "@/types/timeline";
|
import { TimeRange } from "@/types/timeline";
|
||||||
import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
|
import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
|
||||||
|
import {
|
||||||
|
DebugReplayContent,
|
||||||
|
SaveDebugReplayOverlay,
|
||||||
|
} from "./DebugReplayDialog";
|
||||||
import { ExportMode, GeneralFilter } from "@/types/filter";
|
import { ExportMode, GeneralFilter } from "@/types/filter";
|
||||||
import ReviewActivityCalendar from "./ReviewActivityCalendar";
|
import ReviewActivityCalendar from "./ReviewActivityCalendar";
|
||||||
import { SelectSeparator } from "../ui/select";
|
import { SelectSeparator } from "../ui/select";
|
||||||
@ -16,19 +21,32 @@ import {
|
|||||||
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
||||||
import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
|
import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import axios from "axios";
|
import axios, { AxiosError } from "axios";
|
||||||
import SaveExportOverlay from "./SaveExportOverlay";
|
import SaveExportOverlay from "./SaveExportOverlay";
|
||||||
import { isIOS, isMobile } from "react-device-detect";
|
import { isIOS, isMobile } from "react-device-detect";
|
||||||
import { useTranslation } from "react-i18next";
|
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];
|
export type DrawerFeatures = (typeof DRAWER_FEATURES)[number];
|
||||||
const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
|
const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
|
||||||
"export",
|
"export",
|
||||||
"calendar",
|
"calendar",
|
||||||
"filter",
|
"filter",
|
||||||
|
"debug-replay",
|
||||||
];
|
];
|
||||||
|
|
||||||
type MobileReviewSettingsDrawerProps = {
|
type MobileReviewSettingsDrawerProps = {
|
||||||
@ -45,6 +63,10 @@ type MobileReviewSettingsDrawerProps = {
|
|||||||
recordingsSummary?: RecordingsSummary;
|
recordingsSummary?: RecordingsSummary;
|
||||||
allLabels: string[];
|
allLabels: string[];
|
||||||
allZones: string[];
|
allZones: string[];
|
||||||
|
debugReplayMode?: ExportMode;
|
||||||
|
debugReplayRange?: TimeRange;
|
||||||
|
setDebugReplayMode?: (mode: ExportMode) => void;
|
||||||
|
setDebugReplayRange?: (range: TimeRange | undefined) => void;
|
||||||
onUpdateFilter: (filter: ReviewFilter) => void;
|
onUpdateFilter: (filter: ReviewFilter) => void;
|
||||||
setRange: (range: TimeRange | undefined) => void;
|
setRange: (range: TimeRange | undefined) => void;
|
||||||
setMode: (mode: ExportMode) => void;
|
setMode: (mode: ExportMode) => void;
|
||||||
@ -64,13 +86,26 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
recordingsSummary,
|
recordingsSummary,
|
||||||
allLabels,
|
allLabels,
|
||||||
allZones,
|
allZones,
|
||||||
|
debugReplayMode = "none",
|
||||||
|
debugReplayRange,
|
||||||
|
setDebugReplayMode = () => {},
|
||||||
|
setDebugReplayRange = () => {},
|
||||||
onUpdateFilter,
|
onUpdateFilter,
|
||||||
setRange,
|
setRange,
|
||||||
setMode,
|
setMode,
|
||||||
setShowExportPreview,
|
setShowExportPreview,
|
||||||
}: MobileReviewSettingsDrawerProps) {
|
}: 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 [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
|
||||||
|
const [selectedReplayOption, setSelectedReplayOption] = useState<
|
||||||
|
"1" | "5" | "custom" | "timeline"
|
||||||
|
>("1");
|
||||||
|
const [isDebugReplayStarting, setIsDebugReplayStarting] = useState(false);
|
||||||
|
|
||||||
// exports
|
// exports
|
||||||
|
|
||||||
@ -140,6 +175,76 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
});
|
});
|
||||||
}, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]);
|
}, [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
|
// filters
|
||||||
|
|
||||||
const [currentFilter, setCurrentFilter] = useState<GeneralFilter>({
|
const [currentFilter, setCurrentFilter] = useState<GeneralFilter>({
|
||||||
@ -196,6 +301,26 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
{t("filter")}
|
{t("filter")}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (drawerMode == "export") {
|
} else if (drawerMode == "export") {
|
||||||
@ -311,6 +436,47 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
@ -322,6 +488,16 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
onCancel={() => setMode("none")}
|
onCancel={() => setMode("none")}
|
||||||
onPreview={() => setShowExportPreview(true)}
|
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
|
<ExportPreviewDialog
|
||||||
camera={camera}
|
camera={camera}
|
||||||
range={range}
|
range={range}
|
||||||
@ -354,7 +530,9 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</DrawerTrigger>
|
</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}
|
{content}
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@ -12,8 +12,11 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
type EventMenuProps = {
|
type EventMenuProps = {
|
||||||
event: Event;
|
event: Event;
|
||||||
@ -34,9 +37,10 @@ export default function EventMenu({
|
|||||||
}: EventMenuProps) {
|
}: EventMenuProps) {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation("views/explore");
|
const { t } = useTranslation(["views/explore", "views/replay"]);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
|
|
||||||
const handleObjectSelect = () => {
|
const handleObjectSelect = () => {
|
||||||
if (isSelected) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<span tabIndex={0} className="sr-only" />
|
<span tabIndex={0} className="sr-only" />
|
||||||
@ -117,6 +174,19 @@ export default function EventMenu({
|
|||||||
{t("itemMenu.findSimilar.label")}
|
{t("itemMenu.findSimilar.label")}
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenuPortal>
|
</DropdownMenuPortal>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
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;
|
||||||
@ -2,6 +2,7 @@ import { useContext } from "react";
|
|||||||
import { AuthContext } from "@/context/auth-context";
|
import { AuthContext } from "@/context/auth-context";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||||
|
|
||||||
export function useAllowedCameras() {
|
export function useAllowedCameras() {
|
||||||
const { auth } = useContext(AuthContext);
|
const { auth } = useContext(AuthContext);
|
||||||
@ -14,9 +15,11 @@ export function useAllowedCameras() {
|
|||||||
auth.user?.role === "admin" ||
|
auth.user?.role === "admin" ||
|
||||||
!auth.isAuthenticated // anonymous internal port
|
!auth.isAuthenticated // anonymous internal port
|
||||||
) {
|
) {
|
||||||
// return all cameras
|
// return all cameras, excluding replay cameras
|
||||||
return config?.cameras ? Object.keys(config.cameras) : [];
|
return config?.cameras
|
||||||
|
? Object.keys(config.cameras).filter((name) => !isReplayCamera(name))
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return auth.allowedCameras || [];
|
return (auth.allowedCameras || []).filter((name) => !isReplayCamera(name));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,7 @@ type useCameraActivityReturn = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useCameraActivity(
|
export function useCameraActivity(
|
||||||
camera: CameraConfig,
|
camera: CameraConfig | undefined,
|
||||||
revalidateOnFocus: boolean = true,
|
revalidateOnFocus: boolean = true,
|
||||||
): useCameraActivityReturn {
|
): useCameraActivityReturn {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
@ -47,7 +47,7 @@ export function useCameraActivity(
|
|||||||
// init camera activity
|
// init camera activity
|
||||||
|
|
||||||
const { payload: updatedCameraState } = useInitialCameraState(
|
const { payload: updatedCameraState } = useInitialCameraState(
|
||||||
camera.name,
|
camera?.name ?? "",
|
||||||
revalidateOnFocus,
|
revalidateOnFocus,
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -60,7 +60,7 @@ export function useCameraActivity(
|
|||||||
const memoizedAudioState = useDeepMemo(updatedAudioState);
|
const memoizedAudioState = useDeepMemo(updatedAudioState);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (memoizedAudioState) {
|
if (memoizedAudioState && camera?.name) {
|
||||||
setAudioDetections(memoizedAudioState[camera.name]);
|
setAudioDetections(memoizedAudioState[camera.name]);
|
||||||
}
|
}
|
||||||
}, [memoizedAudioState, camera]);
|
}, [memoizedAudioState, camera]);
|
||||||
@ -72,8 +72,8 @@ export function useCameraActivity(
|
|||||||
[objects],
|
[objects],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { payload: cameraEnabled } = useEnabledState(camera.name);
|
const { payload: cameraEnabled } = useEnabledState(camera?.name ?? "");
|
||||||
const { payload: detectingMotion } = useMotionActivity(camera.name);
|
const { payload: detectingMotion } = useMotionActivity(camera?.name ?? "");
|
||||||
const { payload: event } = useFrigateEvents();
|
const { payload: event } = useFrigateEvents();
|
||||||
const updatedEvent = useDeepMemo(event);
|
const updatedEvent = useDeepMemo(event);
|
||||||
|
|
||||||
@ -91,7 +91,7 @@ export function useCameraActivity(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updatedEvent.after.camera !== camera.name) {
|
if (!camera?.name || updatedEvent.after.camera !== camera.name) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,6 +158,10 @@ export function useCameraActivity(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!camera?.name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
cameras[camera.name]?.camera_fps == 0 && stats["service"].uptime > 60
|
cameras[camera.name]?.camera_fps == 0 && stats["service"].uptime > 60
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { useMemo } from "react";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import useDeepMemo from "./use-deep-memo";
|
import useDeepMemo from "./use-deep-memo";
|
||||||
import { capitalizeAll, capitalizeFirstLetter } from "@/utils/stringUtil";
|
import { capitalizeAll, capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||||
|
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||||
import { useFrigateStats } from "@/api/ws";
|
import { useFrigateStats } from "@/api/ws";
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -16,6 +17,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
export default function useStats(stats: FrigateStats | undefined) {
|
export default function useStats(stats: FrigateStats | undefined) {
|
||||||
const { t } = useTranslation(["views/system"]);
|
const { t } = useTranslation(["views/system"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const { data: debugReplayStatus } = useSWR("debug_replay/status", {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
const memoizedStats = useDeepMemo(stats);
|
const memoizedStats = useDeepMemo(stats);
|
||||||
|
|
||||||
@ -74,6 +78,11 @@ export default function useStats(stats: FrigateStats | undefined) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip replay cameras
|
||||||
|
if (isReplayCamera(name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cameraName = config.cameras?.[name]?.friendly_name ?? name;
|
const cameraName = config.cameras?.[name]?.friendly_name ?? name;
|
||||||
if (config.cameras[name].enabled && cam["camera_fps"] == 0) {
|
if (config.cameras[name].enabled && cam["camera_fps"] == 0) {
|
||||||
problems.push({
|
problems.push({
|
||||||
@ -96,7 +105,15 @@ export default function useStats(stats: FrigateStats | undefined) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
|
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
|
||||||
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
|
|
||||||
|
// Skip ffmpeg warnings for replay cameras when debug replay is active
|
||||||
|
if (
|
||||||
|
!isNaN(ffmpegAvg) &&
|
||||||
|
ffmpegAvg >= CameraFfmpegThreshold.error &&
|
||||||
|
!(
|
||||||
|
debugReplayStatus?.active && debugReplayStatus?.replay_camera === name
|
||||||
|
)
|
||||||
|
) {
|
||||||
problems.push({
|
problems.push({
|
||||||
text: t("stats.ffmpegHighCpuUsage", {
|
text: t("stats.ffmpegHighCpuUsage", {
|
||||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||||
@ -119,8 +136,19 @@ export default function useStats(stats: FrigateStats | undefined) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add message if debug replay is active
|
||||||
|
if (debugReplayStatus?.active) {
|
||||||
|
problems.push({
|
||||||
|
text: t("stats.debugReplayActive", {
|
||||||
|
defaultValue: "Debug replay session is active",
|
||||||
|
}),
|
||||||
|
color: "text-selected",
|
||||||
|
relevantLink: "/replay",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return problems;
|
return problems;
|
||||||
}, [config, memoizedStats, t]);
|
}, [config, memoizedStats, t, debugReplayStatus]);
|
||||||
|
|
||||||
return { potentialProblems };
|
return { potentialProblems };
|
||||||
}
|
}
|
||||||
|
|||||||
99
web/src/hooks/use-ws-message-buffer.ts
Normal file
99
web/src/hooks/use-ws-message-buffer.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useWsMessageSubscribe, WsFeedMessage } from "@/api/ws";
|
||||||
|
import { extractCameraName } from "@/utils/wsUtil";
|
||||||
|
|
||||||
|
type UseWsMessageBufferReturn = {
|
||||||
|
messages: WsFeedMessage[];
|
||||||
|
clear: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MessageFilter = {
|
||||||
|
cameraFilter?: string | string[]; // "all", specific camera name, or array of camera names (undefined in array = all)
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useWsMessageBuffer(
|
||||||
|
maxSize: number = 2000,
|
||||||
|
paused: boolean = false,
|
||||||
|
filter?: MessageFilter,
|
||||||
|
): UseWsMessageBufferReturn {
|
||||||
|
const bufferRef = useRef<WsFeedMessage[]>([]);
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
const pausedRef = useRef(paused);
|
||||||
|
const filterRef = useRef(filter);
|
||||||
|
|
||||||
|
pausedRef.current = paused;
|
||||||
|
filterRef.current = filter;
|
||||||
|
|
||||||
|
const batchTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const dirtyRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
batchTimerRef.current = setInterval(() => {
|
||||||
|
if (dirtyRef.current) {
|
||||||
|
dirtyRef.current = false;
|
||||||
|
setVersion((v) => v + 1);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (batchTimerRef.current) {
|
||||||
|
clearInterval(batchTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const shouldIncludeMessage = useCallback((msg: WsFeedMessage): boolean => {
|
||||||
|
const currentFilter = filterRef.current;
|
||||||
|
if (!currentFilter) return true;
|
||||||
|
|
||||||
|
// Check camera filter
|
||||||
|
const cf = currentFilter.cameraFilter;
|
||||||
|
if (cf !== undefined) {
|
||||||
|
if (Array.isArray(cf)) {
|
||||||
|
// Array of cameras: include messages matching any camera in the list
|
||||||
|
const msgCamera = extractCameraName(msg);
|
||||||
|
if (msgCamera && !cf.includes(msgCamera)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (cf !== "all") {
|
||||||
|
// Single string camera filter
|
||||||
|
const msgCamera = extractCameraName(msg);
|
||||||
|
if (msgCamera !== cf) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useWsMessageSubscribe(
|
||||||
|
useCallback(
|
||||||
|
(msg: WsFeedMessage) => {
|
||||||
|
if (pausedRef.current) return;
|
||||||
|
if (!shouldIncludeMessage(msg)) return;
|
||||||
|
|
||||||
|
const buf = bufferRef.current;
|
||||||
|
buf.push(msg);
|
||||||
|
if (buf.length > maxSize) {
|
||||||
|
buf.splice(0, buf.length - maxSize);
|
||||||
|
}
|
||||||
|
dirtyRef.current = true;
|
||||||
|
},
|
||||||
|
[shouldIncludeMessage, maxSize],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
bufferRef.current = [];
|
||||||
|
setVersion((v) => v + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// version is used to trigger re-renders; we spread the buffer
|
||||||
|
// into a new array so that downstream useMemo dependencies
|
||||||
|
// see a new reference and recompute.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const messages = useMemo(() => [...bufferRef.current], [version]);
|
||||||
|
|
||||||
|
return { messages, clear };
|
||||||
|
}
|
||||||
@ -642,4 +642,4 @@ function CaseAssignmentDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Exports;
|
export default Exports;
|
||||||
|
|||||||
@ -35,10 +35,12 @@ import { isIOS, isMobile } from "react-device-detect";
|
|||||||
import { isPWA } from "@/utils/isPWA";
|
import { isPWA } from "@/utils/isPWA";
|
||||||
import { isInIframe } from "@/utils/isIFrame";
|
import { isInIframe } from "@/utils/isIFrame";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import WsMessageFeed from "@/components/ws/WsMessageFeed";
|
||||||
|
|
||||||
function Logs() {
|
function Logs() {
|
||||||
const { t } = useTranslation(["views/system"]);
|
const { t } = useTranslation(["views/system"]);
|
||||||
const [logService, setLogService] = useState<LogType>("frigate");
|
const [logService, setLogService] = useState<LogType>("frigate");
|
||||||
|
const isWebsocket = logService === "websocket";
|
||||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||||
const lazyLogWrapperRef = useRef<HTMLDivElement>(null);
|
const lazyLogWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const [logs, setLogs] = useState<string[]>([]);
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
@ -216,6 +218,12 @@ function Logs() {
|
|||||||
}, [logService, filterSeverity, t]);
|
}, [logService, filterSeverity, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isWebsocket) {
|
||||||
|
setIsLoading(false);
|
||||||
|
setLogs([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setLogs([]);
|
setLogs([]);
|
||||||
lastFetchedIndexRef.current = -1;
|
lastFetchedIndexRef.current = -1;
|
||||||
@ -494,116 +502,128 @@ function Logs() {
|
|||||||
data-nav-item={item}
|
data-nav-item={item}
|
||||||
aria-label={`Select ${item}`}
|
aria-label={`Select ${item}`}
|
||||||
>
|
>
|
||||||
<div className="smart-capitalize">{item}</div>
|
<div
|
||||||
|
className={item !== "websocket" ? "smart-capitalize" : ""}
|
||||||
|
>
|
||||||
|
{item === "websocket" ? t("logs.websocket.label") : item}
|
||||||
|
</div>
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
))}
|
))}
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
<ScrollBar orientation="horizontal" className="h-0" />
|
<ScrollBar orientation="horizontal" className="h-0" />
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<div className="flex items-center gap-2">
|
{!isWebsocket && (
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
className="flex items-center justify-between gap-2"
|
<Button
|
||||||
aria-label={t("logs.copy.label")}
|
className="flex items-center justify-between gap-2"
|
||||||
size="sm"
|
aria-label={t("logs.copy.label")}
|
||||||
onClick={handleCopyLogs}
|
size="sm"
|
||||||
>
|
onClick={handleCopyLogs}
|
||||||
<FaCopy className="text-secondary-foreground" />
|
>
|
||||||
<div className="hidden text-primary md:block">
|
<FaCopy className="text-secondary-foreground" />
|
||||||
{t("logs.copy.label")}
|
<div className="hidden text-primary md:block">
|
||||||
</div>
|
{t("logs.copy.label")}
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex items-center justify-between gap-2"
|
|
||||||
aria-label={t("logs.download.label")}
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDownloadLogs}
|
|
||||||
>
|
|
||||||
<FaDownload className="text-secondary-foreground" />
|
|
||||||
<div className="hidden text-primary md:block">
|
|
||||||
{t("button.download", { ns: "common" })}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
<LogSettingsButton
|
|
||||||
selectedLabels={filterSeverity}
|
|
||||||
updateLabelFilter={setFilterSeverity}
|
|
||||||
logSettings={logSettings}
|
|
||||||
setLogSettings={setLogSettings}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative my-2 flex size-full flex-col overflow-hidden whitespace-pre-wrap rounded-md border border-secondary bg-background_alt font-mono text-xs sm:p-1">
|
|
||||||
<div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
|
|
||||||
<div className="col-span-3 lg:col-span-2">
|
|
||||||
<div className="flex w-full flex-row items-center">
|
|
||||||
<div className="ml-1 min-w-16 smart-capitalize lg:min-w-20">
|
|
||||||
{t("logs.type.label")}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mr-3">{t("logs.type.timestamp")}</div>
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex items-center justify-between gap-2"
|
||||||
|
aria-label={t("logs.download.label")}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDownloadLogs}
|
||||||
|
>
|
||||||
|
<FaDownload className="text-secondary-foreground" />
|
||||||
|
<div className="hidden text-primary md:block">
|
||||||
|
{t("button.download", { ns: "common" })}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<LogSettingsButton
|
||||||
|
selectedLabels={filterSeverity}
|
||||||
|
updateLabelFilter={setFilterSeverity}
|
||||||
|
logSettings={logSettings}
|
||||||
|
setLogSettings={setLogSettings}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isWebsocket ? (
|
||||||
|
<div className="my-2 flex size-full flex-col overflow-hidden rounded-md border border-secondary bg-background_alt">
|
||||||
|
<WsMessageFeed maxSize={2000} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative my-2 flex size-full flex-col overflow-hidden whitespace-pre-wrap rounded-md border border-secondary bg-background_alt font-mono text-xs sm:p-1">
|
||||||
|
<div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
|
||||||
|
<div className="col-span-3 lg:col-span-2">
|
||||||
|
<div className="flex w-full flex-row items-center">
|
||||||
|
<div className="ml-1 min-w-16 smart-capitalize lg:min-w-20">
|
||||||
|
{t("logs.type.label")}
|
||||||
|
</div>
|
||||||
|
<div className="mr-3">{t("logs.type.timestamp")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center",
|
||||||
|
logService == "frigate" ? "col-span-2" : "col-span-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("logs.type.tag")}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"col-span-5 flex items-center",
|
||||||
|
logService == "frigate"
|
||||||
|
? "md:col-span-7 lg:col-span-8"
|
||||||
|
: "md:col-span-8 lg:col-span-9",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-1">{t("logs.type.message")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center",
|
|
||||||
logService == "frigate" ? "col-span-2" : "col-span-1",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t("logs.type.tag")}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"col-span-5 flex items-center",
|
|
||||||
logService == "frigate"
|
|
||||||
? "md:col-span-7 lg:col-span-8"
|
|
||||||
: "md:col-span-8 lg:col-span-9",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-1">{t("logs.type.message")}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref={lazyLogWrapperRef} className="size-full">
|
<div ref={lazyLogWrapperRef} className="size-full">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
) : (
|
) : (
|
||||||
<EnhancedScrollFollow
|
<EnhancedScrollFollow
|
||||||
startFollowing={!isLoading}
|
startFollowing={!isLoading}
|
||||||
onCustomScroll={handleScroll}
|
onCustomScroll={handleScroll}
|
||||||
render={({ follow, onScroll }) => (
|
render={({ follow, onScroll }) => (
|
||||||
<>
|
<>
|
||||||
{follow && !logSettings.disableStreaming && (
|
{follow && !logSettings.disableStreaming && (
|
||||||
<div className="absolute right-1 top-3">
|
<div className="absolute right-1 top-3">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
|
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{t("logs.tips")}</TooltipContent>
|
<TooltipContent>{t("logs.tips")}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<LazyLog
|
<LazyLog
|
||||||
ref={lazyLogRef}
|
ref={lazyLogRef}
|
||||||
enableLineNumbers={false}
|
enableLineNumbers={false}
|
||||||
selectableLines
|
selectableLines
|
||||||
lineClassName="text-primary bg-background"
|
lineClassName="text-primary bg-background"
|
||||||
highlightLineClassName="bg-primary/20"
|
highlightLineClassName="bg-primary/20"
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
formatPart={formatPart}
|
formatPart={formatPart}
|
||||||
text={logs.join("\n")}
|
text={logs.join("\n")}
|
||||||
follow={follow}
|
follow={follow}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
loadingComponent={
|
loadingComponent={
|
||||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
}
|
}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
725
web/src/pages/Replay.tsx
Normal file
725
web/src/pages/Replay.tsx
Normal file
@ -0,0 +1,725 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import Heading from "@/components/ui/heading";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { getIconForLabel } from "@/utils/iconUtil";
|
||||||
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
import { ObjectType } from "@/types/ws";
|
||||||
|
import WsMessageFeed from "@/components/ws/WsMessageFeed";
|
||||||
|
import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate";
|
||||||
|
|
||||||
|
import { LuExternalLink, LuInfo, LuSettings } from "react-icons/lu";
|
||||||
|
import { LuSquare } from "react-icons/lu";
|
||||||
|
import { MdReplay } from "react-icons/md";
|
||||||
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
|
import Logo from "@/components/Logo";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
|
import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer";
|
||||||
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
|
|
||||||
|
type DebugReplayStatus = {
|
||||||
|
active: boolean;
|
||||||
|
replay_camera: string | null;
|
||||||
|
source_camera: string | null;
|
||||||
|
start_time: number | null;
|
||||||
|
end_time: number | null;
|
||||||
|
live_ready: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DebugOptions = {
|
||||||
|
bbox: boolean;
|
||||||
|
timestamp: boolean;
|
||||||
|
zones: boolean;
|
||||||
|
mask: boolean;
|
||||||
|
motion: boolean;
|
||||||
|
regions: boolean;
|
||||||
|
paths: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: DebugOptions = {
|
||||||
|
bbox: true,
|
||||||
|
timestamp: false,
|
||||||
|
zones: false,
|
||||||
|
mask: false,
|
||||||
|
motion: true,
|
||||||
|
regions: false,
|
||||||
|
paths: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEBUG_OPTION_KEYS: (keyof DebugOptions)[] = [
|
||||||
|
"bbox",
|
||||||
|
"timestamp",
|
||||||
|
"zones",
|
||||||
|
"mask",
|
||||||
|
"motion",
|
||||||
|
"regions",
|
||||||
|
"paths",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEBUG_OPTION_I18N_KEY: Record<keyof DebugOptions, string> = {
|
||||||
|
bbox: "boundingBoxes",
|
||||||
|
timestamp: "timestamp",
|
||||||
|
zones: "zones",
|
||||||
|
mask: "mask",
|
||||||
|
motion: "motion",
|
||||||
|
regions: "regions",
|
||||||
|
paths: "paths",
|
||||||
|
};
|
||||||
|
|
||||||
|
const REPLAY_INIT_SKELETON_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
export default function Replay() {
|
||||||
|
const { t } = useTranslation(["views/replay", "views/settings", "common"]);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { getLocaleDocUrl } = useDocDomain();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: status,
|
||||||
|
mutate: refreshStatus,
|
||||||
|
isLoading,
|
||||||
|
} = useSWR<DebugReplayStatus>("debug_replay/status", {
|
||||||
|
refreshInterval: 1000,
|
||||||
|
});
|
||||||
|
const [isInitializing, setIsInitializing] = useState(true);
|
||||||
|
|
||||||
|
// Refresh status immediately on mount to avoid showing "no session" briefly
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeStatus = async () => {
|
||||||
|
await refreshStatus();
|
||||||
|
setIsInitializing(false);
|
||||||
|
};
|
||||||
|
initializeStatus();
|
||||||
|
}, [refreshStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status?.live_ready) {
|
||||||
|
setShowReplayInitSkeleton(false);
|
||||||
|
}
|
||||||
|
}, [status?.live_ready]);
|
||||||
|
|
||||||
|
const [options, setOptions] = useState<DebugOptions>(DEFAULT_OPTIONS);
|
||||||
|
const [isStopping, setIsStopping] = useState(false);
|
||||||
|
const [configDialogOpen, setConfigDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const searchParams = useMemo(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const key of DEBUG_OPTION_KEYS) {
|
||||||
|
params.set(key, options[key] ? "1" : "0");
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
const handleSetOption = useCallback(
|
||||||
|
(key: keyof DebugOptions, value: boolean) => {
|
||||||
|
setOptions((prev) => ({ ...prev, [key]: value }));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
setIsStopping(true);
|
||||||
|
axios
|
||||||
|
.post("debug_replay/stop")
|
||||||
|
.then(() => {
|
||||||
|
toast.success(t("dialog.toast.stopped"), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
refreshStatus();
|
||||||
|
navigate("/review");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(t("dialog.toast.stopError", { error: errorMessage }), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsStopping(false);
|
||||||
|
});
|
||||||
|
}, [navigate, refreshStatus, t]);
|
||||||
|
|
||||||
|
// Camera activity for the replay camera
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
const replayCameraName = status?.replay_camera ?? "";
|
||||||
|
const replayCameraConfig = replayCameraName
|
||||||
|
? config?.cameras?.[replayCameraName]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const { objects } = useCameraActivity(replayCameraConfig);
|
||||||
|
|
||||||
|
const [showReplayInitSkeleton, setShowReplayInitSkeleton] = useState(false);
|
||||||
|
|
||||||
|
// debug draw
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [debugDraw, setDebugDraw] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!status?.active || !status.replay_camera) {
|
||||||
|
setShowReplayInitSkeleton(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowReplayInitSkeleton(true);
|
||||||
|
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
setShowReplayInitSkeleton(false);
|
||||||
|
}, REPLAY_INIT_SKELETON_TIMEOUT_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [status?.active, status?.replay_camera]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status?.live_ready) {
|
||||||
|
setShowReplayInitSkeleton(false);
|
||||||
|
}
|
||||||
|
}, [status?.live_ready]);
|
||||||
|
|
||||||
|
// Format time range for display
|
||||||
|
const timeRangeDisplay = useMemo(() => {
|
||||||
|
if (!status?.start_time || !status?.end_time) return "";
|
||||||
|
const start = new Date(status.start_time * 1000).toLocaleString();
|
||||||
|
const end = new Date(status.end_time * 1000).toLocaleString();
|
||||||
|
return `${start} — ${end}`;
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
if (isInitializing || (isLoading && !status?.active)) {
|
||||||
|
return (
|
||||||
|
<div className="flex size-full items-center justify-center">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No active session
|
||||||
|
if (!status?.active) {
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col items-center justify-center gap-4 p-8">
|
||||||
|
<MdReplay className="size-12" />
|
||||||
|
<Heading as="h2" className="text-center">
|
||||||
|
{t("page.noSession")}
|
||||||
|
</Heading>
|
||||||
|
<p className="max-w-md text-center text-muted-foreground">
|
||||||
|
{t("page.noSessionDesc")}
|
||||||
|
</p>
|
||||||
|
<Button variant="default" onClick={() => navigate("/review")}>
|
||||||
|
{t("page.goToRecordings")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col overflow-hidden">
|
||||||
|
<Toaster position="top-center" closeButton={true} />
|
||||||
|
|
||||||
|
{/* Top bar */}
|
||||||
|
<div className="flex min-h-12 items-center justify-between border-b border-secondary px-2 py-2 md:min-h-16 md:px-3 md:py-3">
|
||||||
|
{isMobile && (
|
||||||
|
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2.5 rounded-lg"
|
||||||
|
aria-label={t("label.back", { ns: "common" })}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||||
|
{isDesktop && (
|
||||||
|
<div className="text-primary">
|
||||||
|
{t("button.back", { ns: "common" })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => setConfigDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<LuSettings className="size-4" />
|
||||||
|
<span className="hidden md:inline">{t("page.configuration")}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 text-white"
|
||||||
|
disabled={isStopping}
|
||||||
|
>
|
||||||
|
{isStopping && <ActivityIndicator className="size-4" />}
|
||||||
|
<span className="hidden md:inline">{t("page.stopReplay")}</span>
|
||||||
|
<LuSquare className="size-4 md:hidden" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("page.confirmStop.title")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("page.confirmStop.description")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t("page.confirmStop.cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleStop}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "destructive" }),
|
||||||
|
"text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("page.confirmStop.confirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden pb-2 md:flex-row">
|
||||||
|
{/* Camera feed */}
|
||||||
|
<div className="flex max-h-[40%] px-2 pt-2 md:h-dvh md:max-h-full md:w-7/12 md:grow md:px-4 md:pt-2">
|
||||||
|
{isStopping ? (
|
||||||
|
<div className="flex size-full items-center justify-center rounded-lg bg-background_alt">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
|
<ActivityIndicator className="size-8" />
|
||||||
|
<div className="text-secondary-foreground">
|
||||||
|
{t("page.stoppingReplay")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
status.replay_camera && (
|
||||||
|
<div className="relative size-full min-h-10" ref={containerRef}>
|
||||||
|
<AutoUpdatingCameraImage
|
||||||
|
className="size-full"
|
||||||
|
cameraClasses="relative w-full h-full flex flex-col justify-start"
|
||||||
|
searchParams={searchParams}
|
||||||
|
camera={status.replay_camera}
|
||||||
|
showFps={false}
|
||||||
|
/>
|
||||||
|
{debugDraw && (
|
||||||
|
<DebugDrawingLayer
|
||||||
|
containerRef={containerRef}
|
||||||
|
cameraWidth={
|
||||||
|
config?.cameras?.[status.source_camera ?? ""]?.detect
|
||||||
|
.width ?? 1280
|
||||||
|
}
|
||||||
|
cameraHeight={
|
||||||
|
config?.cameras?.[status.source_camera ?? ""]?.detect
|
||||||
|
.height ?? 720
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showReplayInitSkeleton && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-10 size-full rounded-lg bg-background">
|
||||||
|
<Skeleton className="size-full rounded-lg" />
|
||||||
|
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center gap-2">
|
||||||
|
<ActivityIndicator className="size-8" />
|
||||||
|
<div className="text-secondary-foreground">
|
||||||
|
{t("page.initializingReplay")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Side panel */}
|
||||||
|
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-4/12">
|
||||||
|
<div className="mb-5 flex flex-col space-y-2">
|
||||||
|
<Heading as="h3" className="mb-0">
|
||||||
|
{t("title")}
|
||||||
|
</Heading>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="smart-capitalize">{status.source_camera}</span>
|
||||||
|
{timeRangeDisplay && (
|
||||||
|
<>
|
||||||
|
<span className="hidden md:inline">•</span>
|
||||||
|
<span className="hidden md:inline">{timeRangeDisplay}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-5 space-y-3 text-sm text-muted-foreground">
|
||||||
|
<p>{t("description")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tabs defaultValue="debug" className="flex h-full w-full flex-col">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="debug">
|
||||||
|
{t("debug.debugging", { ns: "views/settings" })}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="objects">{t("page.objects")}</TabsTrigger>
|
||||||
|
<TabsTrigger value="messages">
|
||||||
|
{t("websocket_messages")}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="debug" className="mt-2">
|
||||||
|
<div className="mt-2 space-y-6">
|
||||||
|
<div className="my-2.5 flex flex-col gap-2.5">
|
||||||
|
{DEBUG_OPTION_KEYS.map((key) => {
|
||||||
|
const i18nKey = DEBUG_OPTION_I18N_KEY[key];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex w-full flex-row items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex flex-col">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label
|
||||||
|
className="mb-0 cursor-pointer text-primary smart-capitalize"
|
||||||
|
htmlFor={`debug-${key}`}
|
||||||
|
>
|
||||||
|
{t(`debug.${i18nKey}.title`, {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
{(key === "bbox" ||
|
||||||
|
key === "motion" ||
|
||||||
|
key === "regions" ||
|
||||||
|
key === "paths") && (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div className="cursor-pointer p-0">
|
||||||
|
<LuInfo className="size-4" />
|
||||||
|
<span className="sr-only">
|
||||||
|
{t("button.info", { ns: "common" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 text-sm">
|
||||||
|
{key === "bbox" ? (
|
||||||
|
<>
|
||||||
|
<p className="mb-2">
|
||||||
|
<strong>
|
||||||
|
{t(
|
||||||
|
"debug.boundingBoxes.colors.label",
|
||||||
|
{
|
||||||
|
ns: "views/settings",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc space-y-1 pl-5">
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
debug.boundingBoxes.colors.info
|
||||||
|
</Trans>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Trans ns="views/settings">
|
||||||
|
{`debug.${i18nKey}.tips`}
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t(`debug.${i18nKey}.desc`, {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id={`debug-${key}`}
|
||||||
|
className="ml-1"
|
||||||
|
checked={options[key]}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleSetOption(key, checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{isDesktop && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<div className="flex w-full flex-row items-center justify-between">
|
||||||
|
<div className="mb-2 flex flex-col">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label
|
||||||
|
className="mb-0 cursor-pointer text-primary smart-capitalize"
|
||||||
|
htmlFor="debugdraw"
|
||||||
|
>
|
||||||
|
{t("debug.objectShapeFilterDrawing.title", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div className="cursor-pointer p-0">
|
||||||
|
<LuInfo className="size-4" />
|
||||||
|
<span className="sr-only">
|
||||||
|
{t("button.info", { ns: "common" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 text-sm">
|
||||||
|
{t("debug.objectShapeFilterDrawing.tips", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
<div className="mt-2 flex items-center text-primary">
|
||||||
|
<Link
|
||||||
|
to={getLocaleDocUrl(
|
||||||
|
"configuration/object_filters#object-shape",
|
||||||
|
)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline"
|
||||||
|
>
|
||||||
|
{t("readTheDocumentation", {
|
||||||
|
ns: "common",
|
||||||
|
})}
|
||||||
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t("debug.objectShapeFilterDrawing.desc", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
key={"draw"}
|
||||||
|
className="ml-1"
|
||||||
|
id="debug_draw"
|
||||||
|
checked={debugDraw}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
setDebugDraw(isChecked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="objects" className="mt-2">
|
||||||
|
<ObjectList
|
||||||
|
cameraConfig={replayCameraConfig}
|
||||||
|
objects={objects}
|
||||||
|
config={config}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent
|
||||||
|
value="messages"
|
||||||
|
className="mt-2 flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col overflow-hidden rounded-md border border-secondary">
|
||||||
|
<WsMessageFeed
|
||||||
|
maxSize={2000}
|
||||||
|
lockedCamera={status.replay_camera ?? undefined}
|
||||||
|
showCameraBadge={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={configDialogOpen} onOpenChange={setConfigDialogOpen}>
|
||||||
|
<DialogContent className="scrollbar-container max-h-[90dvh] overflow-y-auto sm:max-w-xl md:max-w-3xl lg:max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("page.configuration")}</DialogTitle>
|
||||||
|
<DialogDescription className="mb-5">
|
||||||
|
{t("page.configurationDesc")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ConfigSectionTemplate
|
||||||
|
sectionKey="motion"
|
||||||
|
level="replay"
|
||||||
|
cameraName={status.replay_camera ?? undefined}
|
||||||
|
skipSave
|
||||||
|
noStickyButtons
|
||||||
|
requiresRestart={false}
|
||||||
|
collapsible
|
||||||
|
defaultCollapsed={false}
|
||||||
|
showTitle
|
||||||
|
showOverrideIndicator={false}
|
||||||
|
/>
|
||||||
|
<ConfigSectionTemplate
|
||||||
|
sectionKey="objects"
|
||||||
|
level="replay"
|
||||||
|
cameraName={status.replay_camera ?? undefined}
|
||||||
|
skipSave
|
||||||
|
noStickyButtons
|
||||||
|
requiresRestart={false}
|
||||||
|
collapsible
|
||||||
|
defaultCollapsed={false}
|
||||||
|
showTitle
|
||||||
|
showOverrideIndicator={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ObjectListProps = {
|
||||||
|
cameraConfig?: CameraConfig;
|
||||||
|
objects?: ObjectType[];
|
||||||
|
config?: FrigateConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
|
||||||
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
|
||||||
|
const colormap = useMemo(() => {
|
||||||
|
if (!config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return config.model?.colormap;
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const getColorForObjectName = useCallback(
|
||||||
|
(objectName: string) => {
|
||||||
|
return colormap && colormap[objectName]
|
||||||
|
? `rgb(${colormap[objectName][2]}, ${colormap[objectName][1]}, ${colormap[objectName][0]})`
|
||||||
|
: "rgb(128, 128, 128)";
|
||||||
|
},
|
||||||
|
[colormap],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!objects || objects.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-3 text-center text-sm text-muted-foreground">
|
||||||
|
{t("debug.noObjects", { ns: "views/settings" })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
{objects.map((obj: ObjectType) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={obj.id}
|
||||||
|
className="flex flex-col rounded-lg bg-secondary/30 p-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-row items-center gap-3 pb-1">
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: obj.stationary
|
||||||
|
? "rgb(110,110,110)"
|
||||||
|
: getColorForObjectName(obj.label),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getIconForLabel(obj.label, "object", "size-4 text-white")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{getTranslatedLabel(obj.label)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 pl-1 text-xs text-primary-variant">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
{t("debug.objectShapeFilterDrawing.score", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
:
|
||||||
|
</span>
|
||||||
|
<span className="text-primary">
|
||||||
|
{obj.score ? (obj.score * 100).toFixed(1) : "-"}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{obj.ratio && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
{t("debug.objectShapeFilterDrawing.ratio", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
:
|
||||||
|
</span>
|
||||||
|
<span className="text-primary">{obj.ratio.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{obj.area && cameraConfig && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
{t("debug.objectShapeFilterDrawing.area", {
|
||||||
|
ns: "views/settings",
|
||||||
|
})}
|
||||||
|
:
|
||||||
|
</span>
|
||||||
|
<span className="text-primary">
|
||||||
|
{obj.area} px (
|
||||||
|
{(
|
||||||
|
(obj.area /
|
||||||
|
(cameraConfig.detect.width *
|
||||||
|
cameraConfig.detect.height)) *
|
||||||
|
100
|
||||||
|
).toFixed(2)}
|
||||||
|
%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ export type LogLine = {
|
|||||||
content: string;
|
content: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logTypes = ["frigate", "go2rtc", "nginx"] as const;
|
export const logTypes = ["frigate", "go2rtc", "nginx", "websocket"] as const;
|
||||||
export type LogType = (typeof logTypes)[number];
|
export type LogType = (typeof logTypes)[number];
|
||||||
|
|
||||||
export type LogSettingsType = {
|
export type LogSettingsType = {
|
||||||
|
|||||||
@ -148,3 +148,15 @@ export function detectCameraAudioFeatures(
|
|||||||
audioOutput: !!audioOutput,
|
audioOutput: !!audioOutput,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REPLAY_CAMERA_PREFIX = "_replay_";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a camera name is a debug replay camera.
|
||||||
|
*
|
||||||
|
* @param name - The camera name to check
|
||||||
|
* @returns true if the camera is a replay camera
|
||||||
|
*/
|
||||||
|
export function isReplayCamera(name: string): boolean {
|
||||||
|
return name.startsWith(REPLAY_CAMERA_PREFIX);
|
||||||
|
}
|
||||||
|
|||||||
@ -514,13 +514,18 @@ const mergeSectionConfig = (
|
|||||||
|
|
||||||
export function getSectionConfig(
|
export function getSectionConfig(
|
||||||
sectionKey: string,
|
sectionKey: string,
|
||||||
level: "global" | "camera",
|
level: "global" | "camera" | "replay",
|
||||||
): SectionConfig {
|
): SectionConfig {
|
||||||
const entry = sectionConfigs[sectionKey];
|
const entry = sectionConfigs[sectionKey];
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const overrides = level === "global" ? entry.global : entry.camera;
|
const overrides =
|
||||||
|
level === "global"
|
||||||
|
? entry.global
|
||||||
|
: level === "replay"
|
||||||
|
? entry.replay
|
||||||
|
: entry.camera;
|
||||||
return mergeSectionConfig(entry.base, overrides);
|
return mergeSectionConfig(entry.base, overrides);
|
||||||
}
|
}
|
||||||
|
|||||||
53
web/src/utils/wsUtil.ts
Normal file
53
web/src/utils/wsUtil.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { WsFeedMessage } from "@/api/ws";
|
||||||
|
|
||||||
|
const EVENT_TOPICS = new Set([
|
||||||
|
"events",
|
||||||
|
"reviews",
|
||||||
|
"tracked_object_update",
|
||||||
|
"triggers",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SYSTEM_TOPICS = new Set([
|
||||||
|
"stats",
|
||||||
|
"model_state",
|
||||||
|
"job_state",
|
||||||
|
"embeddings_reindex_progress",
|
||||||
|
"audio_transcription_state",
|
||||||
|
"birdseye_layout",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function extractCameraName(message: WsFeedMessage): string | null {
|
||||||
|
// Try extracting from topic pattern: {camera}/motion, {camera}/audio/rms, etc.
|
||||||
|
const topicParts = message.topic.split("/");
|
||||||
|
if (
|
||||||
|
topicParts.length >= 2 &&
|
||||||
|
!EVENT_TOPICS.has(message.topic) &&
|
||||||
|
!SYSTEM_TOPICS.has(message.topic) &&
|
||||||
|
message.topic !== "camera_activity" &&
|
||||||
|
message.topic !== "audio_detections" &&
|
||||||
|
message.topic !== "restart" &&
|
||||||
|
message.topic !== "notification_test"
|
||||||
|
) {
|
||||||
|
return topicParts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try extracting from payload
|
||||||
|
try {
|
||||||
|
const data =
|
||||||
|
typeof message.payload === "string"
|
||||||
|
? JSON.parse(message.payload)
|
||||||
|
: message.payload;
|
||||||
|
|
||||||
|
if (typeof data === "object" && data !== null) {
|
||||||
|
if ("camera" in data) return data.camera as string;
|
||||||
|
if ("after" in data && data.after?.camera)
|
||||||
|
return data.after.camera as string;
|
||||||
|
if ("before" in data && data.before?.camera)
|
||||||
|
return data.before.camera as string;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import ReviewCard from "@/components/card/ReviewCard";
|
import ReviewCard from "@/components/card/ReviewCard";
|
||||||
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
|
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
|
||||||
|
import DebugReplayDialog from "@/components/overlay/DebugReplayDialog";
|
||||||
import ExportDialog from "@/components/overlay/ExportDialog";
|
import ExportDialog from "@/components/overlay/ExportDialog";
|
||||||
|
import ActionsDropdown from "@/components/overlay/ActionsDropdown";
|
||||||
import PreviewPlayer, {
|
import PreviewPlayer, {
|
||||||
PreviewController,
|
PreviewController,
|
||||||
} from "@/components/player/PreviewPlayer";
|
} from "@/components/player/PreviewPlayer";
|
||||||
@ -199,6 +201,11 @@ export function RecordingView({
|
|||||||
const [exportRange, setExportRange] = useState<TimeRange>();
|
const [exportRange, setExportRange] = useState<TimeRange>();
|
||||||
const [showExportPreview, setShowExportPreview] = useState(false);
|
const [showExportPreview, setShowExportPreview] = useState(false);
|
||||||
|
|
||||||
|
// debug replay
|
||||||
|
|
||||||
|
const [debugReplayMode, setDebugReplayMode] = useState<ExportMode>("none");
|
||||||
|
const [debugReplayRange, setDebugReplayRange] = useState<TimeRange>();
|
||||||
|
|
||||||
// move to next clip
|
// move to next clip
|
||||||
|
|
||||||
const onClipEnded = useCallback(() => {
|
const onClipEnded = useCallback(() => {
|
||||||
@ -269,7 +276,7 @@ export function RecordingView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrubbing || exportRange) {
|
if (scrubbing || exportRange || debugReplayRange) {
|
||||||
if (
|
if (
|
||||||
currentTime > currentTimeRange.before + 60 ||
|
currentTime > currentTimeRange.before + 60 ||
|
||||||
currentTime < currentTimeRange.after - 60
|
currentTime < currentTimeRange.after - 60
|
||||||
@ -591,6 +598,23 @@ export function RecordingView({
|
|||||||
selected={mainCamera}
|
selected={mainCamera}
|
||||||
onSelectCamera={onSelectCamera}
|
onSelectCamera={onSelectCamera}
|
||||||
/>
|
/>
|
||||||
|
{isDesktop && (
|
||||||
|
<DebugReplayDialog
|
||||||
|
camera={mainCamera}
|
||||||
|
currentTime={currentTime}
|
||||||
|
latestTime={timeRange.before}
|
||||||
|
mode={debugReplayMode}
|
||||||
|
range={debugReplayRange}
|
||||||
|
setRange={(range: TimeRange | undefined) => {
|
||||||
|
setDebugReplayRange(range);
|
||||||
|
|
||||||
|
if (range != undefined) {
|
||||||
|
mainControllerRef.current?.pause();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
setMode={setDebugReplayMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isDesktop && (
|
{isDesktop && (
|
||||||
<ExportDialog
|
<ExportDialog
|
||||||
camera={mainCamera}
|
camera={mainCamera}
|
||||||
@ -639,6 +663,28 @@ export function RecordingView({
|
|||||||
setMotionOnly={() => {}}
|
setMotionOnly={() => {}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isDesktop && (
|
||||||
|
<ActionsDropdown
|
||||||
|
onDebugReplayClick={() => {
|
||||||
|
const now = new Date(timeRange.before * 1000);
|
||||||
|
now.setHours(now.getHours() - 1);
|
||||||
|
setDebugReplayRange({
|
||||||
|
after: now.getTime() / 1000,
|
||||||
|
before: timeRange.before,
|
||||||
|
});
|
||||||
|
setDebugReplayMode("select");
|
||||||
|
}}
|
||||||
|
onExportClick={() => {
|
||||||
|
const now = new Date(timeRange.before * 1000);
|
||||||
|
now.setHours(now.getHours() - 1);
|
||||||
|
setExportRange({
|
||||||
|
before: timeRange.before,
|
||||||
|
after: now.getTime() / 1000,
|
||||||
|
});
|
||||||
|
setExportMode("select");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isDesktop ? (
|
{isDesktop ? (
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
className="*:rounded-md *:px-3 *:py-4"
|
className="*:rounded-md *:px-3 *:py-4"
|
||||||
@ -688,6 +734,16 @@ export function RecordingView({
|
|||||||
showExportPreview={showExportPreview}
|
showExportPreview={showExportPreview}
|
||||||
allLabels={reviewFilterList.labels}
|
allLabels={reviewFilterList.labels}
|
||||||
allZones={reviewFilterList.zones}
|
allZones={reviewFilterList.zones}
|
||||||
|
debugReplayMode={debugReplayMode}
|
||||||
|
debugReplayRange={debugReplayRange}
|
||||||
|
setDebugReplayMode={setDebugReplayMode}
|
||||||
|
setDebugReplayRange={(range: TimeRange | undefined) => {
|
||||||
|
setDebugReplayRange(range);
|
||||||
|
|
||||||
|
if (range != undefined) {
|
||||||
|
mainControllerRef.current?.pause();
|
||||||
|
}
|
||||||
|
}}
|
||||||
onUpdateFilter={updateFilter}
|
onUpdateFilter={updateFilter}
|
||||||
setRange={setExportRange}
|
setRange={setExportRange}
|
||||||
setMode={setExportMode}
|
setMode={setExportMode}
|
||||||
@ -758,7 +814,9 @@ export function RecordingView({
|
|||||||
timeRange={currentTimeRange}
|
timeRange={currentTimeRange}
|
||||||
cameraPreviews={allPreviews ?? []}
|
cameraPreviews={allPreviews ?? []}
|
||||||
startTimestamp={playbackStart}
|
startTimestamp={playbackStart}
|
||||||
hotKeys={exportMode != "select"}
|
hotKeys={
|
||||||
|
exportMode != "select" && debugReplayMode != "select"
|
||||||
|
}
|
||||||
fullscreen={fullscreen}
|
fullscreen={fullscreen}
|
||||||
onTimestampUpdate={(timestamp) => {
|
onTimestampUpdate={(timestamp) => {
|
||||||
setPlayerTime(timestamp);
|
setPlayerTime(timestamp);
|
||||||
@ -772,7 +830,11 @@ export function RecordingView({
|
|||||||
onControllerReady={(controller) => {
|
onControllerReady={(controller) => {
|
||||||
mainControllerRef.current = controller;
|
mainControllerRef.current = controller;
|
||||||
}}
|
}}
|
||||||
isScrubbing={scrubbing || exportMode == "timeline"}
|
isScrubbing={
|
||||||
|
scrubbing ||
|
||||||
|
exportMode == "timeline" ||
|
||||||
|
debugReplayMode == "timeline"
|
||||||
|
}
|
||||||
supportsFullscreen={supportsFullScreen}
|
supportsFullscreen={supportsFullScreen}
|
||||||
setFullResolution={setFullResolution}
|
setFullResolution={setFullResolution}
|
||||||
toggleFullscreen={toggleFullscreen}
|
toggleFullscreen={toggleFullscreen}
|
||||||
@ -840,18 +902,29 @@ export function RecordingView({
|
|||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
mainCamera={mainCamera}
|
mainCamera={mainCamera}
|
||||||
timelineType={
|
timelineType={
|
||||||
(exportRange == undefined ? timelineType : "timeline") ??
|
(exportRange == undefined && debugReplayRange == undefined
|
||||||
"timeline"
|
? timelineType
|
||||||
|
: "timeline") ?? "timeline"
|
||||||
}
|
}
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
mainCameraReviewItems={mainCameraReviewItems}
|
mainCameraReviewItems={mainCameraReviewItems}
|
||||||
activeReviewItem={activeReviewItem}
|
activeReviewItem={activeReviewItem}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
exportRange={exportMode == "timeline" ? exportRange : undefined}
|
exportRange={
|
||||||
|
exportMode == "timeline"
|
||||||
|
? exportRange
|
||||||
|
: debugReplayMode == "timeline"
|
||||||
|
? debugReplayRange
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
setCurrentTime={setCurrentTime}
|
setCurrentTime={setCurrentTime}
|
||||||
manuallySetCurrentTime={manuallySetCurrentTime}
|
manuallySetCurrentTime={manuallySetCurrentTime}
|
||||||
setScrubbing={setScrubbing}
|
setScrubbing={setScrubbing}
|
||||||
setExportRange={setExportRange}
|
setExportRange={
|
||||||
|
debugReplayMode == "timeline"
|
||||||
|
? setDebugReplayRange
|
||||||
|
: setExportRange
|
||||||
|
}
|
||||||
onAnalysisOpen={onAnalysisOpen}
|
onAnalysisOpen={onAnalysisOpen}
|
||||||
isPlaying={mainControllerRef?.current?.isPlaying() ?? false}
|
isPlaying={mainControllerRef?.current?.isPlaying() ?? false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user