Compare commits

...

9 Commits
dev ... 0.18

Author SHA1 Message Date
Andrew Roberts
7fb8d9b050 Camera-specific hwaccel settings for timelapse exports (correct base) (#21386)
* added hwaccel_args to camera.record.export config struct

* populate camera.record.export.hwaccel_args with a cascade up to camera then global if 'auto'

* use new hwaccel args in export

* added documentation for camera-specific hwaccel export

* fix c/p error

* missed an import

* fleshed out the docs and comments a bit

* ruff lint

* separated out the tips in the doc

* fix documentation

* fix and simplify reference config doc
2025-12-22 09:10:40 -07:00
Nicolas Mowen
b8bc98a423 Refactor temperature reporting for detectors and implement Hailo temp reading (#21395)
* Add Hailo temperature retrieval

* Refactor `get_hailo_temps()` to use ctxmanager

* Show Hailo temps in system UI

* Move hailo_platform import to get_hailo_temps

* Refactor temperatures calculations to use within detector block

* Adjust webUI to handle new location

---------

Co-authored-by: tigattack <10629864+tigattack@users.noreply.github.com>
2025-12-22 08:25:38 -07:00
Nicolas Mowen
f9e06bb7b7 Export filter UI (#21322)
* Get started on export filters

* implement basic filter

* Implement filtering and adjust api

* Improve filter handling

* Improve navigation

* Cleanup

* handle scrolling
2025-12-16 16:10:48 -06:00
Josh Hawkins
7cc16161b3 Camera connection quality indicator (#21297)
* add camera connection quality metrics and indicator

* formatting

* move stall calcs to watchdog

* clean up

* change watchdog to 1s and separately track time for ffmpeg retry_interval

* implement status caching to reduce message volume
2025-12-15 14:02:03 -07:00
Nicolas Mowen
08311a6ee2 Case management UI (#21299)
* Refactor export cards to match existing cards in other UI pages

* Show cases separately from exports

* Add proper filtering and display of cases

* Add ability to edit and select cases for exports

* Cleanup typing

* Hide if no unassigned

* Cleanup hiding logic

* fix scrolling

* Improve layout
2025-12-15 13:10:50 -07:00
Josh Hawkins
a08c044144 refactor vainfo to search for first GPU (#21296)
use existing LibvaGpuSelector to pick appropritate libva device
2025-12-15 08:58:50 -07:00
Nicolas Mowen
5cced22f65 implement case management for export apis (#21295) 2025-12-15 08:54:13 -07:00
Nicolas Mowen
b962c95725 Create scaffolding for case management (#21293) 2025-12-15 08:28:52 -07:00
Nicolas Mowen
0cbec25494 Update version 2025-12-15 07:46:31 -07:00
31 changed files with 1664 additions and 242 deletions

View File

@@ -1,7 +1,7 @@
default_target: local
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
VERSION = 0.17.0
VERSION = 0.18.0
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
BOARDS= #Initialized empty

View File

@@ -139,7 +139,11 @@ record:
:::tip
When using `hwaccel_args` globally hardware encoding is used for time lapse generation. The encoder determines its own behavior so the resulting file size may be undesirably large.
When using `hwaccel_args`, hardware encoding is used for timelapse generation. This setting can be overridden for a specific camera (e.g., when camera resolution exceeds hardware encoder limits); set `cameras.<camera>.record.export.hwaccel_args` with the appropriate settings. Using an unrecognized value or empty string will fall back to software encoding (libx264).
:::tip
The encoder determines its own behavior so the resulting file size may be undesirably large.
To reduce the output file size the ffmpeg parameter `-qp n` can be utilized (where `n` stands for the value of the quantisation parameter). The value can be adjusted to get an acceptable tradeoff between quality and file size for the given scenario.
:::

View File

@@ -534,6 +534,8 @@ record:
# The -r (framerate) dictates how smooth the output video is.
# So the args would be -vf setpts=0.02*PTS -r 30 in that case.
timelapse_args: "-vf setpts=0.04*PTS -r 30"
# Optional: Global hardware acceleration settings for timelapse exports. (default: inherit)
hwaccel_args: auto
# Optional: Recording Preview Settings
preview:
# Optional: Quality of recording preview (default: shown below).
@@ -835,6 +837,11 @@ cameras:
# Optional: camera specific output args (default: inherit)
# output_args:
# Optional: camera specific hwaccel args for timelapse export (default: inherit)
# record:
# export:
# hwaccel_args:
# Optional: timeout for highest scoring image before allowing it
# to be replaced by a newer image. (default: shown below)
best_image_timeout: 60

View File

@@ -32,6 +32,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
from frigate.models import Event, Timeline
from frigate.stats.prometheus import get_metrics, update_metrics
from frigate.util.builtin import (
@@ -458,7 +459,15 @@ def config_set(request: Request, body: AppConfigSetBody):
@router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())])
def vainfo():
vainfo = vainfo_hwaccel()
# Use LibvaGpuSelector to pick an appropriate libva device (if available)
selected_gpu = ""
try:
selected_gpu = _gpu_selector.get_gpu_arg(FFMPEG_HWACCEL_VAAPI, 0) or ""
except Exception:
selected_gpu = ""
# If selected_gpu is empty, pass None to vainfo_hwaccel to run plain `vainfo`.
vainfo = vainfo_hwaccel(device_name=selected_gpu or None)
return JSONResponse(
content={
"return_code": vainfo.returncode,

View File

@@ -0,0 +1,35 @@
from typing import Optional
from pydantic import BaseModel, Field
class ExportCaseCreateBody(BaseModel):
"""Request body for creating a new export case."""
name: str = Field(max_length=100, description="Friendly name of the export case")
description: Optional[str] = Field(
default=None, description="Optional description of the export case"
)
class ExportCaseUpdateBody(BaseModel):
"""Request body for updating an existing export case."""
name: Optional[str] = Field(
default=None,
max_length=100,
description="Updated friendly name of the export case",
)
description: Optional[str] = Field(
default=None, description="Updated description of the export case"
)
class ExportCaseAssignBody(BaseModel):
"""Request body for assigning or unassigning an export to a case."""
export_case_id: Optional[str] = Field(
default=None,
max_length=30,
description="Case ID to assign to the export, or null to unassign",
)

View File

@@ -1,4 +1,4 @@
from typing import Union
from typing import Optional, Union
from pydantic import BaseModel, Field
from pydantic.json_schema import SkipJsonSchema
@@ -18,3 +18,9 @@ class ExportRecordingsBody(BaseModel):
)
name: str = Field(title="Friendly name", default=None, max_length=256)
image_path: Union[str, SkipJsonSchema[None]] = None
export_case_id: Optional[str] = Field(
default=None,
title="Export case ID",
max_length=30,
description="ID of the export case to assign this export to",
)

View File

@@ -0,0 +1,22 @@
from typing import List, Optional
from pydantic import BaseModel, Field
class ExportCaseModel(BaseModel):
"""Model representing a single export case."""
id: str = Field(description="Unique identifier for the export case")
name: str = Field(description="Friendly name of the export case")
description: Optional[str] = Field(
default=None, description="Optional description of the export case"
)
created_at: float = Field(
description="Unix timestamp when the export case was created"
)
updated_at: float = Field(
description="Unix timestamp when the export case was last updated"
)
ExportCasesResponse = List[ExportCaseModel]

View File

@@ -15,6 +15,9 @@ class ExportModel(BaseModel):
in_progress: bool = Field(
description="Whether the export is currently being processed"
)
export_case_id: Optional[str] = Field(
default=None, description="ID of the export case this export belongs to"
)
class StartExportResponse(BaseModel):

View File

@@ -4,10 +4,10 @@ import logging
import random
import string
from pathlib import Path
from typing import List
from typing import List, Optional
import psutil
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import JSONResponse
from pathvalidate import sanitize_filepath
from peewee import DoesNotExist
@@ -19,8 +19,17 @@ from frigate.api.auth import (
require_camera_access,
require_role,
)
from frigate.api.defs.request.export_case_body import (
ExportCaseAssignBody,
ExportCaseCreateBody,
ExportCaseUpdateBody,
)
from frigate.api.defs.request.export_recordings_body import ExportRecordingsBody
from frigate.api.defs.request.export_rename_body import ExportRenameBody
from frigate.api.defs.response.export_case_response import (
ExportCaseModel,
ExportCasesResponse,
)
from frigate.api.defs.response.export_response import (
ExportModel,
ExportsResponse,
@@ -29,7 +38,7 @@ from frigate.api.defs.response.export_response import (
from frigate.api.defs.response.generic_response import GenericResponse
from frigate.api.defs.tags import Tags
from frigate.const import CLIPS_DIR, EXPORT_DIR
from frigate.models import Export, Previews, Recordings
from frigate.models import Export, ExportCase, Previews, Recordings
from frigate.record.export import (
PlaybackFactorEnum,
PlaybackSourceEnum,
@@ -52,17 +61,182 @@ router = APIRouter(tags=[Tags.export])
)
def get_exports(
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
export_case_id: Optional[str] = None,
cameras: Optional[str] = Query(default="all"),
start_date: Optional[float] = None,
end_date: Optional[float] = None,
):
exports = (
Export.select()
.where(Export.camera << allowed_cameras)
.order_by(Export.date.desc())
.dicts()
.iterator()
)
query = Export.select().where(Export.camera << allowed_cameras)
if export_case_id is not None:
if export_case_id == "unassigned":
query = query.where(Export.export_case.is_null(True))
else:
query = query.where(Export.export_case == export_case_id)
if cameras and cameras != "all":
requested = set(cameras.split(","))
filtered_cameras = list(requested.intersection(allowed_cameras))
if not filtered_cameras:
return JSONResponse(content=[])
query = query.where(Export.camera << filtered_cameras)
if start_date is not None:
query = query.where(Export.date >= start_date)
if end_date is not None:
query = query.where(Export.date <= end_date)
exports = query.order_by(Export.date.desc()).dicts().iterator()
return JSONResponse(content=[e for e in exports])
@router.get(
"/cases",
response_model=ExportCasesResponse,
dependencies=[Depends(allow_any_authenticated())],
summary="Get export cases",
description="Gets all export cases from the database.",
)
def get_export_cases():
cases = (
ExportCase.select().order_by(ExportCase.created_at.desc()).dicts().iterator()
)
return JSONResponse(content=[c for c in cases])
@router.post(
"/cases",
response_model=ExportCaseModel,
dependencies=[Depends(require_role(["admin"]))],
summary="Create export case",
description="Creates a new export case.",
)
def create_export_case(body: ExportCaseCreateBody):
case = ExportCase.create(
id="".join(random.choices(string.ascii_lowercase + string.digits, k=12)),
name=body.name,
description=body.description,
created_at=Path().stat().st_mtime,
updated_at=Path().stat().st_mtime,
)
return JSONResponse(content=model_to_dict(case))
@router.get(
"/cases/{case_id}",
response_model=ExportCaseModel,
dependencies=[Depends(allow_any_authenticated())],
summary="Get a single export case",
description="Gets a specific export case by ID.",
)
def get_export_case(case_id: str):
try:
case = ExportCase.get(ExportCase.id == case_id)
return JSONResponse(content=model_to_dict(case))
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found"},
status_code=404,
)
@router.patch(
"/cases/{case_id}",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Update export case",
description="Updates an existing export case.",
)
def update_export_case(case_id: str, body: ExportCaseUpdateBody):
try:
case = ExportCase.get(ExportCase.id == case_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found"},
status_code=404,
)
if body.name is not None:
case.name = body.name
if body.description is not None:
case.description = body.description
case.save()
return JSONResponse(
content={"success": True, "message": "Successfully updated export case."}
)
@router.delete(
"/cases/{case_id}",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Delete export case",
description="""Deletes an export case.\n Exports that reference this case will have their export_case set to null.\n """,
)
def delete_export_case(case_id: str):
try:
case = ExportCase.get(ExportCase.id == case_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found"},
status_code=404,
)
# Unassign exports from this case but keep the exports themselves
Export.update(export_case=None).where(Export.export_case == case).execute()
case.delete_instance()
return JSONResponse(
content={"success": True, "message": "Successfully deleted export case."}
)
@router.patch(
"/export/{export_id}/case",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Assign export to case",
description=(
"Assigns an export to a case, or unassigns it if export_case_id is null."
),
)
async def assign_export_case(
export_id: str,
body: ExportCaseAssignBody,
request: Request,
):
try:
export: Export = Export.get(Export.id == export_id)
await require_camera_access(export.camera, request=request)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export not found."},
status_code=404,
)
if body.export_case_id is not None:
try:
ExportCase.get(ExportCase.id == body.export_case_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found."},
status_code=404,
)
export.export_case = body.export_case_id
else:
export.export_case = None
export.save()
return JSONResponse(
content={"success": True, "message": "Successfully updated export case."}
)
@router.post(
"/export/{camera_name}/start/{start_time}/end/{end_time}",
response_model=StartExportResponse,
@@ -93,6 +267,16 @@ def export_recording(
friendly_name = body.name
existing_image = sanitize_filepath(body.image_path) if body.image_path else None
export_case_id = body.export_case_id
if export_case_id is not None:
try:
ExportCase.get(ExportCase.id == export_case_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found"},
status_code=404,
)
# Ensure that existing_image is a valid path
if existing_image and not existing_image.startswith(CLIPS_DIR):
return JSONResponse(
@@ -161,6 +345,7 @@ def export_recording(
if playback_source in PlaybackSourceEnum.__members__.values()
else PlaybackSourceEnum.recordings
),
export_case_id,
)
exporter.start()
return JSONResponse(

View File

@@ -19,6 +19,8 @@ class CameraMetrics:
process_pid: Synchronized
capture_process_pid: Synchronized
ffmpeg_pid: Synchronized
reconnects_last_hour: Synchronized
stalls_last_hour: Synchronized
def __init__(self, manager: SyncManager):
self.camera_fps = manager.Value("d", 0)
@@ -35,6 +37,8 @@ class CameraMetrics:
self.process_pid = manager.Value("i", 0)
self.capture_process_pid = manager.Value("i", 0)
self.ffmpeg_pid = manager.Value("i", 0)
self.reconnects_last_hour = manager.Value("i", 0)
self.stalls_last_hour = manager.Value("i", 0)
class PTZMetrics:

View File

@@ -1,5 +1,5 @@
from enum import Enum
from typing import Optional
from typing import Optional, Union
from pydantic import Field
@@ -70,6 +70,9 @@ class RecordExportConfig(FrigateBaseModel):
timelapse_args: str = Field(
default=DEFAULT_TIME_LAPSE_FFMPEG_ARGS, title="Timelapse Args"
)
hwaccel_args: Union[str, list[str]] = Field(
default="auto", title="Export-specific FFmpeg hardware acceleration arguments."
)
class RecordConfig(FrigateBaseModel):

View File

@@ -523,6 +523,14 @@ class FrigateConfig(FrigateBaseModel):
if camera_config.ffmpeg.hwaccel_args == "auto":
camera_config.ffmpeg.hwaccel_args = self.ffmpeg.hwaccel_args
# Resolve export hwaccel_args: camera export -> camera ffmpeg -> global ffmpeg
# This allows per-camera override for exports (e.g., when camera resolution
# exceeds hardware encoder limits)
if camera_config.record.export.hwaccel_args == "auto":
camera_config.record.export.hwaccel_args = (
camera_config.ffmpeg.hwaccel_args
)
for input in camera_config.ffmpeg.inputs:
need_detect_dimensions = "detect" in input.roles and (
camera_config.detect.height is None

View File

@@ -80,6 +80,14 @@ class Recordings(Model):
regions = IntegerField(null=True)
class ExportCase(Model):
id = CharField(null=False, primary_key=True, max_length=30)
name = CharField(index=True, max_length=100)
description = TextField(null=True)
created_at = DateTimeField()
updated_at = DateTimeField()
class Export(Model):
id = CharField(null=False, primary_key=True, max_length=30)
camera = CharField(index=True, max_length=20)
@@ -88,6 +96,12 @@ class Export(Model):
video_path = CharField(unique=True)
thumb_path = CharField(unique=True)
in_progress = BooleanField()
export_case = ForeignKeyField(
ExportCase,
null=True,
backref="exports",
column_name="export_case_id",
)
class ReviewSegment(Model):

View File

@@ -64,6 +64,7 @@ class RecordingExporter(threading.Thread):
end_time: int,
playback_factor: PlaybackFactorEnum,
playback_source: PlaybackSourceEnum,
export_case_id: Optional[str] = None,
) -> None:
super().__init__()
self.config = config
@@ -75,6 +76,7 @@ class RecordingExporter(threading.Thread):
self.end_time = end_time
self.playback_factor = playback_factor
self.playback_source = playback_source
self.export_case_id = export_case_id
# ensure export thumb dir
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
@@ -226,7 +228,7 @@ class RecordingExporter(threading.Thread):
ffmpeg_cmd = (
parse_preset_hardware_acceleration_encode(
self.config.ffmpeg.ffmpeg_path,
self.config.ffmpeg.hwaccel_args,
self.config.cameras[self.camera].record.export.hwaccel_args,
f"-an {ffmpeg_input}",
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart",
EncodeTypeEnum.timelapse,
@@ -317,7 +319,7 @@ class RecordingExporter(threading.Thread):
ffmpeg_cmd = (
parse_preset_hardware_acceleration_encode(
self.config.ffmpeg.ffmpeg_path,
self.config.ffmpeg.hwaccel_args,
self.config.cameras[self.camera].record.export.hwaccel_args,
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}",
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}",
EncodeTypeEnum.timelapse,
@@ -348,17 +350,20 @@ class RecordingExporter(threading.Thread):
video_path = f"{EXPORT_DIR}/{self.camera}_{filename_start_datetime}-{filename_end_datetime}_{cleaned_export_id}.mp4"
thumb_path = self.save_thumbnail(self.export_id)
Export.insert(
{
Export.id: self.export_id,
Export.camera: self.camera,
Export.name: export_name,
Export.date: self.start_time,
Export.video_path: video_path,
Export.thumb_path: thumb_path,
Export.in_progress: True,
}
).execute()
export_values = {
Export.id: self.export_id,
Export.camera: self.camera,
Export.name: export_name,
Export.date: self.start_time,
Export.video_path: video_path,
Export.thumb_path: thumb_path,
Export.in_progress: True,
}
if self.export_case_id is not None:
export_values[Export.export_case] = self.export_case_id
Export.insert(export_values).execute()
try:
if self.playback_source == PlaybackSourceEnum.recordings:

View File

@@ -22,6 +22,7 @@ from frigate.util.services import (
get_bandwidth_stats,
get_cpu_stats,
get_fs_type,
get_hailo_temps,
get_intel_gpu_stats,
get_jetson_stats,
get_nvidia_gpu_stats,
@@ -91,9 +92,76 @@ def get_temperatures() -> dict[str, float]:
if temp is not None:
temps[apex] = temp
# Get temperatures for Hailo devices
temps.update(get_hailo_temps())
return temps
def get_detector_temperature(
detector_type: str,
detector_index_by_type: dict[str, int],
) -> Optional[float]:
"""Get temperature for a specific detector based on its type."""
if detector_type == "edgetpu":
# Get temperatures for all attached Corals
base = "/sys/class/apex/"
if os.path.isdir(base):
apex_devices = sorted(os.listdir(base))
index = detector_index_by_type.get("edgetpu", 0)
if index < len(apex_devices):
apex_name = apex_devices[index]
temp = read_temperature(os.path.join(base, apex_name, "temp"))
if temp is not None:
return temp
elif detector_type == "hailo8l":
# Get temperatures for Hailo devices
hailo_temps = get_hailo_temps()
if hailo_temps:
hailo_device_names = sorted(hailo_temps.keys())
index = detector_index_by_type.get("hailo8l", 0)
if index < len(hailo_device_names):
device_name = hailo_device_names[index]
return hailo_temps[device_name]
return None
def get_detector_stats(
stats_tracking: StatsTrackingTypes,
) -> dict[str, dict[str, Any]]:
"""Get stats for all detectors, including temperatures based on detector type."""
detector_stats: dict[str, dict[str, Any]] = {}
detector_type_indices: dict[str, int] = {}
for name, detector in stats_tracking["detectors"].items():
pid = detector.detect_process.pid if detector.detect_process else None
detector_type = detector.detector_config.type
# Keep track of the index for each detector type to match temperatures correctly
current_index = detector_type_indices.get(detector_type, 0)
detector_type_indices[detector_type] = current_index + 1
detector_stat = {
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
# issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards
"detection_start": detector.detection_start.value, # type: ignore[attr-defined]
# issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards
"pid": pid,
}
temp = get_detector_temperature(detector_type, {detector_type: current_index})
if temp is not None:
detector_stat["temperature"] = round(temp, 1)
detector_stats[name] = detector_stat
return detector_stats
def get_processing_stats(
config: FrigateConfig, stats: dict[str, str], hwaccel_errors: list[str]
) -> None:
@@ -279,6 +347,32 @@ def stats_snapshot(
if camera_stats.capture_process_pid.value
else None
)
# Calculate connection quality based on current state
# This is computed at stats-collection time so offline cameras
# correctly show as unusable rather than excellent
expected_fps = config.cameras[name].detect.fps
current_fps = camera_stats.camera_fps.value
reconnects = camera_stats.reconnects_last_hour.value
stalls = camera_stats.stalls_last_hour.value
if current_fps < 0.1:
quality_str = "unusable"
elif reconnects == 0 and current_fps >= 0.9 * expected_fps and stalls < 5:
quality_str = "excellent"
elif reconnects <= 2 and current_fps >= 0.6 * expected_fps:
quality_str = "fair"
elif reconnects > 10 or current_fps < 1.0 or stalls > 100:
quality_str = "unusable"
else:
quality_str = "poor"
connection_quality = {
"connection_quality": quality_str,
"expected_fps": expected_fps,
"reconnects_last_hour": reconnects,
"stalls_last_hour": stalls,
}
stats["cameras"][name] = {
"camera_fps": round(camera_stats.camera_fps.value, 2),
"process_fps": round(camera_stats.process_fps.value, 2),
@@ -290,20 +384,10 @@ def stats_snapshot(
"ffmpeg_pid": ffmpeg_pid,
"audio_rms": round(camera_stats.audio_rms.value, 4),
"audio_dBFS": round(camera_stats.audio_dBFS.value, 4),
**connection_quality,
}
stats["detectors"] = {}
for name, detector in stats_tracking["detectors"].items():
pid = detector.detect_process.pid if detector.detect_process else None
stats["detectors"][name] = {
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
# issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards
"detection_start": detector.detection_start.value, # type: ignore[attr-defined]
# issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards
"pid": pid,
}
stats["detectors"] = get_detector_stats(stats_tracking)
stats["camera_fps"] = round(total_camera_fps, 2)
stats["process_fps"] = round(total_process_fps, 2)
stats["skipped_fps"] = round(total_skipped_fps, 2)
@@ -389,7 +473,6 @@ def stats_snapshot(
"version": VERSION,
"latest_version": stats_tracking["latest_frigate_version"],
"storage": {},
"temperatures": get_temperatures(),
"last_updated": int(time.time()),
}

View File

@@ -549,6 +549,53 @@ def get_jetson_stats() -> Optional[dict[int, dict]]:
return results
def get_hailo_temps() -> dict[str, float]:
"""Get temperatures for Hailo devices."""
try:
from hailo_platform import Device
except ModuleNotFoundError:
return {}
temps = {}
try:
device_ids = Device.scan()
for i, device_id in enumerate(device_ids):
try:
with Device(device_id) as device:
temp_info = device.control.get_chip_temperature()
# Get board name and normalise it
identity = device.control.identify()
board_name = None
for line in str(identity).split("\n"):
if line.startswith("Board Name:"):
board_name = (
line.split(":", 1)[1].strip().lower().replace("-", "")
)
break
if not board_name:
board_name = f"hailo{i}"
# Use indexed name if multiple devices, otherwise just the board name
device_name = (
f"{board_name}-{i}" if len(device_ids) > 1 else board_name
)
# ts1_temperature is also available, but appeared to be the same as ts0 in testing.
temps[device_name] = round(temp_info.ts0_temperature, 1)
except Exception as e:
logger.debug(
f"Failed to get temperature for Hailo device {device_id}: {e}"
)
continue
except Exception as e:
logger.debug(f"Failed to scan for Hailo devices: {e}")
return temps
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
"""Run ffprobe on stream."""
clean_path = escape_special_characters(path)
@@ -584,12 +631,17 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro
def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess:
"""Run vainfo."""
ffprobe_cmd = (
["vainfo"]
if not device_name
else ["vainfo", "--display", "drm", "--device", f"/dev/dri/{device_name}"]
)
return sp.run(ffprobe_cmd, capture_output=True)
if not device_name:
cmd = ["vainfo"]
else:
if os.path.isabs(device_name) and device_name.startswith("/dev/dri/"):
device_path = device_name
else:
device_path = f"/dev/dri/{device_name}"
cmd = ["vainfo", "--display", "drm", "--device", device_path]
return sp.run(cmd, capture_output=True)
def get_nvidia_driver_info() -> dict[str, Any]:

View File

@@ -3,6 +3,7 @@ import queue
import subprocess as sp
import threading
import time
from collections import deque
from datetime import datetime, timedelta, timezone
from multiprocessing import Queue, Value
from multiprocessing.synchronize import Event as MpEvent
@@ -115,6 +116,7 @@ def capture_frames(
frame_rate.start()
skipped_eps = EventsPerSecond()
skipped_eps.start()
config_subscriber = CameraConfigUpdateSubscriber(
None, {config.name: config}, [CameraConfigUpdateEnum.enabled]
)
@@ -179,6 +181,9 @@ class CameraWatchdog(threading.Thread):
camera_fps,
skipped_fps,
ffmpeg_pid,
stalls,
reconnects,
detection_frame,
stop_event,
):
threading.Thread.__init__(self)
@@ -199,6 +204,10 @@ class CameraWatchdog(threading.Thread):
self.frame_index = 0
self.stop_event = stop_event
self.sleeptime = self.config.ffmpeg.retry_interval
self.reconnect_timestamps = deque()
self.stalls = stalls
self.reconnects = reconnects
self.detection_frame = detection_frame
self.config_subscriber = CameraConfigUpdateSubscriber(
None,
@@ -213,6 +222,35 @@ class CameraWatchdog(threading.Thread):
self.latest_invalid_segment_time: float = 0
self.latest_cache_segment_time: float = 0
# Stall tracking (based on last processed frame)
self._stall_timestamps: deque[float] = deque()
self._stall_active: bool = False
# Status caching to reduce message volume
self._last_detect_status: str | None = None
self._last_record_status: str | None = None
self._last_status_update_time: float = 0.0
def _send_detect_status(self, status: str, now: float) -> None:
"""Send detect status only if changed or retry_interval has elapsed."""
if (
status != self._last_detect_status
or (now - self._last_status_update_time) >= self.sleeptime
):
self.requestor.send_data(f"{self.config.name}/status/detect", status)
self._last_detect_status = status
self._last_status_update_time = now
def _send_record_status(self, status: str, now: float) -> None:
"""Send record status only if changed or retry_interval has elapsed."""
if (
status != self._last_record_status
or (now - self._last_status_update_time) >= self.sleeptime
):
self.requestor.send_data(f"{self.config.name}/status/record", status)
self._last_record_status = status
self._last_status_update_time = now
def _update_enabled_state(self) -> bool:
"""Fetch the latest config and update enabled state."""
self.config_subscriber.check_for_updates()
@@ -239,6 +277,14 @@ class CameraWatchdog(threading.Thread):
else:
self.ffmpeg_detect_process.wait()
# Update reconnects
now = datetime.now().timestamp()
self.reconnect_timestamps.append(now)
while self.reconnect_timestamps and self.reconnect_timestamps[0] < now - 3600:
self.reconnect_timestamps.popleft()
if self.reconnects:
self.reconnects.value = len(self.reconnect_timestamps)
# Wait for old capture thread to fully exit before starting a new one
if self.capture_thread is not None and self.capture_thread.is_alive():
self.logger.info("Waiting for capture thread to exit...")
@@ -261,7 +307,10 @@ class CameraWatchdog(threading.Thread):
self.start_all_ffmpeg()
time.sleep(self.sleeptime)
while not self.stop_event.wait(self.sleeptime):
last_restart_time = datetime.now().timestamp()
# 1 second watchdog loop
while not self.stop_event.wait(1):
enabled = self._update_enabled_state()
if enabled != self.was_enabled:
if enabled:
@@ -277,12 +326,9 @@ class CameraWatchdog(threading.Thread):
self.stop_all_ffmpeg()
# update camera status
self.requestor.send_data(
f"{self.config.name}/status/detect", "disabled"
)
self.requestor.send_data(
f"{self.config.name}/status/record", "disabled"
)
now = datetime.now().timestamp()
self._send_detect_status("disabled", now)
self._send_record_status("disabled", now)
self.was_enabled = enabled
continue
@@ -321,36 +367,44 @@ class CameraWatchdog(threading.Thread):
now = datetime.now().timestamp()
# Check if enough time has passed to allow ffmpeg restart (backoff pacing)
time_since_last_restart = now - last_restart_time
can_restart = time_since_last_restart >= self.sleeptime
if not self.capture_thread.is_alive():
self.requestor.send_data(f"{self.config.name}/status/detect", "offline")
self._send_detect_status("offline", now)
self.camera_fps.value = 0
self.logger.error(
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
)
self.reset_capture_thread(terminate=False)
if can_restart:
self.reset_capture_thread(terminate=False)
last_restart_time = now
elif self.camera_fps.value >= (self.config.detect.fps + 10):
self.fps_overflow_count += 1
if self.fps_overflow_count == 3:
self.requestor.send_data(
f"{self.config.name}/status/detect", "offline"
)
self._send_detect_status("offline", now)
self.fps_overflow_count = 0
self.camera_fps.value = 0
self.logger.info(
f"{self.config.name} exceeded fps limit. Exiting ffmpeg..."
)
self.reset_capture_thread(drain_output=False)
if can_restart:
self.reset_capture_thread(drain_output=False)
last_restart_time = now
elif now - self.capture_thread.current_frame.value > 20:
self.requestor.send_data(f"{self.config.name}/status/detect", "offline")
self._send_detect_status("offline", now)
self.camera_fps.value = 0
self.logger.info(
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
)
self.reset_capture_thread()
if can_restart:
self.reset_capture_thread()
last_restart_time = now
else:
# process is running normally
self.requestor.send_data(f"{self.config.name}/status/detect", "online")
self._send_detect_status("online", now)
self.fps_overflow_count = 0
for p in self.ffmpeg_other_processes:
@@ -421,9 +475,7 @@ class CameraWatchdog(threading.Thread):
continue
else:
self.requestor.send_data(
f"{self.config.name}/status/record", "online"
)
self._send_record_status("online", now)
p["latest_segment_time"] = self.latest_cache_segment_time
if poll is None:
@@ -439,6 +491,34 @@ class CameraWatchdog(threading.Thread):
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
)
# Update stall metrics based on last processed frame timestamp
now = datetime.now().timestamp()
processed_ts = (
float(self.detection_frame.value) if self.detection_frame else 0.0
)
if processed_ts > 0:
delta = now - processed_ts
observed_fps = (
self.camera_fps.value
if self.camera_fps.value > 0
else self.config.detect.fps
)
interval = 1.0 / max(observed_fps, 0.1)
stall_threshold = max(2.0 * interval, 2.0)
if delta > stall_threshold:
if not self._stall_active:
self._stall_timestamps.append(now)
self._stall_active = True
else:
self._stall_active = False
while self._stall_timestamps and self._stall_timestamps[0] < now - 3600:
self._stall_timestamps.popleft()
if self.stalls:
self.stalls.value = len(self._stall_timestamps)
self.stop_all_ffmpeg()
self.logpipe.close()
self.config_subscriber.stop()
@@ -576,6 +656,9 @@ class CameraCapture(FrigateProcess):
self.camera_metrics.camera_fps,
self.camera_metrics.skipped_fps,
self.camera_metrics.ffmpeg_pid,
self.camera_metrics.stalls_last_hour,
self.camera_metrics.reconnects_last_hour,
self.camera_metrics.detection_frame,
self.stop_event,
)
camera_watchdog.start()

View File

@@ -0,0 +1,50 @@
"""Peewee migrations -- 033_create_export_case_table.py.
Some examples (model - class or model name)::
> Model = migrator.orm['model_name'] # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.python(func, *args, **kwargs) # Run python code
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.drop_index(model, *col_names)
> migrator.add_not_null(model, *field_names)
> migrator.drop_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
"""
import peewee as pw
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.sql(
"""
CREATE TABLE IF NOT EXISTS "exportcase" (
"id" VARCHAR(30) NOT NULL PRIMARY KEY,
"name" VARCHAR(100) NOT NULL,
"description" TEXT NULL,
"created_at" DATETIME NOT NULL,
"updated_at" DATETIME NOT NULL
)
"""
)
migrator.sql(
'CREATE INDEX IF NOT EXISTS "exportcase_name" ON "exportcase" ("name")'
)
migrator.sql(
'CREATE INDEX IF NOT EXISTS "exportcase_created_at" ON "exportcase" ("created_at")'
)
def rollback(migrator, database, fake=False, **kwargs):
pass

View File

@@ -0,0 +1,40 @@
"""Peewee migrations -- 034_add_export_case_to_exports.py.
Some examples (model - class or model name)::
> Model = migrator.orm['model_name'] # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.python(func, *args, **kwargs) # Run python code
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.drop_index(model, *col_names)
> migrator.add_not_null(model, *field_names)
> migrator.drop_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
"""
import peewee as pw
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
# Add nullable export_case_id column to export table
migrator.sql('ALTER TABLE "export" ADD COLUMN "export_case_id" VARCHAR(30) NULL')
# Index for faster case-based queries
migrator.sql(
'CREATE INDEX IF NOT EXISTS "export_export_case_id" ON "export" ("export_case_id")'
)
def rollback(migrator, database, fake=False, **kwargs):
pass

View File

@@ -2,6 +2,10 @@
"documentTitle": "Export - Frigate",
"search": "Search",
"noExports": "No exports found",
"headings": {
"cases": "Cases",
"uncategorizedExports": "Uncategorized Exports"
},
"deleteExport": "Delete Export",
"deleteExport.desc": "Are you sure you want to delete {{exportName}}?",
"editExport": {
@@ -13,11 +17,21 @@
"shareExport": "Share export",
"downloadVideo": "Download video",
"editName": "Edit name",
"deleteExport": "Delete export"
"deleteExport": "Delete export",
"assignToCase": "Add to case"
},
"toast": {
"error": {
"renameExportFailed": "Failed to rename export: {{errorMessage}}"
"renameExportFailed": "Failed to rename export: {{errorMessage}}",
"assignCaseFailed": "Failed to update case assignment: {{errorMessage}}"
}
},
"caseDialog": {
"title": "Add to case",
"description": "Choose an existing case or create a new one.",
"selectLabel": "Case",
"newCaseOption": "Create new case",
"nameLabel": "Case name",
"descriptionLabel": "Description"
}
}

View File

@@ -151,6 +151,17 @@
"cameraDetectionsPerSecond": "{{camName}} detections per second",
"cameraSkippedDetectionsPerSecond": "{{camName}} skipped detections per second"
},
"connectionQuality": {
"title": "Connection Quality",
"excellent": "Excellent",
"fair": "Fair",
"poor": "Poor",
"unusable": "Unusable",
"fps": "FPS",
"expectedFps": "Expected FPS",
"reconnectsLastHour": "Reconnects (last hour)",
"stallsLastHour": "Stalls (last hour)"
},
"toast": {
"success": {
"copyToClipboard": "Copied probe data to clipboard."

View File

@@ -0,0 +1,76 @@
import { useTranslation } from "react-i18next";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
type ConnectionQualityIndicatorProps = {
quality: "excellent" | "fair" | "poor" | "unusable";
expectedFps: number;
reconnects: number;
stalls: number;
};
export function ConnectionQualityIndicator({
quality,
expectedFps,
reconnects,
stalls,
}: ConnectionQualityIndicatorProps) {
const { t } = useTranslation(["views/system"]);
const getColorClass = (quality: string): string => {
switch (quality) {
case "excellent":
return "bg-success";
case "fair":
return "bg-yellow-500";
case "poor":
return "bg-orange-500";
case "unusable":
return "bg-destructive";
default:
return "bg-gray-500";
}
};
const qualityLabel = t(`cameras.connectionQuality.${quality}`);
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"inline-block size-3 cursor-pointer rounded-full",
getColorClass(quality),
)}
/>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<div className="space-y-2">
<div className="font-semibold">
{t("cameras.connectionQuality.title")}
</div>
<div className="text-sm">
<div className="capitalize">{qualityLabel}</div>
<div className="mt-2 space-y-1 text-xs">
<div>
{t("cameras.connectionQuality.expectedFps")}:{" "}
{expectedFps.toFixed(1)} {t("cameras.connectionQuality.fps")}
</div>
<div>
{t("cameras.connectionQuality.reconnectsLastHour")}:{" "}
{reconnects}
</div>
<div>
{t("cameras.connectionQuality.stallsLastHour")}: {stalls}
</div>
</div>
</div>
</div>
</TooltipContent>
</Tooltip>
);
}

View File

@@ -1,9 +1,8 @@
import ActivityIndicator from "../indicators/activity-indicator";
import { LuTrash } from "react-icons/lu";
import { Button } from "../ui/button";
import { useCallback, useState } from "react";
import { isDesktop, isMobile } from "react-device-detect";
import { FaDownload, FaPlay, FaShareAlt } from "react-icons/fa";
import { isMobile } from "react-device-detect";
import { FiMoreVertical } from "react-icons/fi";
import { Skeleton } from "../ui/skeleton";
import {
Dialog,
@@ -14,35 +13,62 @@ import {
} from "../ui/dialog";
import { Input } from "../ui/input";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { DeleteClipType, Export } from "@/types/export";
import { MdEditSquare } from "react-icons/md";
import { DeleteClipType, Export, ExportCase } from "@/types/export";
import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
import { shareOrCopy } from "@/utils/browserUtil";
import { useTranslation } from "react-i18next";
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
import BlurredIconButton from "../button/BlurredIconButton";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useIsAdmin } from "@/hooks/use-is-admin";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { FaFolder } from "react-icons/fa";
type ExportProps = {
type CaseCardProps = {
className: string;
exportCase: ExportCase;
onSelect: () => void;
};
export function CaseCard({ className, exportCase, onSelect }: CaseCardProps) {
return (
<div
className={cn(
"relative flex aspect-video size-full cursor-pointer items-center justify-center rounded-lg bg-secondary md:rounded-2xl",
className,
)}
onClick={() => onSelect()}
>
<div className="absolute bottom-2 left-2 flex items-center justify-start gap-2">
<FaFolder />
<div className="capitalize">{exportCase.name}</div>
</div>
</div>
);
}
type ExportCardProps = {
className: string;
exportedRecording: Export;
onSelect: (selected: Export) => void;
onRename: (original: string, update: string) => void;
onDelete: ({ file, exportName }: DeleteClipType) => void;
onAssignToCase?: (selected: Export) => void;
};
export default function ExportCard({
export function ExportCard({
className,
exportedRecording,
onSelect,
onRename,
onDelete,
}: ExportProps) {
onAssignToCase,
}: ExportCardProps) {
const { t } = useTranslation(["views/exports"]);
const isAdmin = useIsAdmin();
const [hovered, setHovered] = useState(false);
const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0,
);
@@ -136,12 +162,14 @@ export default function ExportCard({
<div
className={cn(
"relative flex aspect-video items-center justify-center rounded-lg bg-black md:rounded-2xl",
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
className,
)}
onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
onClick={isDesktop ? undefined : () => setHovered(!hovered)}
onClick={() => {
if (!exportedRecording.in_progress) {
onSelect(exportedRecording);
}
}}
>
{exportedRecording.in_progress ? (
<ActivityIndicator />
@@ -158,95 +186,88 @@ export default function ExportCard({
)}
</>
)}
{hovered && (
<>
<div className="absolute inset-0 rounded-lg bg-black bg-opacity-60 md:rounded-2xl" />
<div className="absolute right-3 top-2">
<div className="flex items-center justify-center gap-4">
{!exportedRecording.in_progress && (
<Tooltip>
<TooltipTrigger asChild>
<BlurredIconButton
onClick={() =>
shareOrCopy(
`${baseUrl}export?id=${exportedRecording.id}`,
exportedRecording.name.replaceAll("_", " "),
)
}
>
<FaShareAlt className="size-4" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>{t("tooltip.shareExport")}</TooltipContent>
</Tooltip>
)}
{!exportedRecording.in_progress && (
{!exportedRecording.in_progress && (
<div className="absolute bottom-2 right-3 z-40">
<DropdownMenu modal={false}>
<DropdownMenuTrigger>
<BlurredIconButton
aria-label={t("tooltip.editName")}
onClick={(e) => e.stopPropagation()}
>
<FiMoreVertical className="size-5" />
</BlurredIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.shareExport")}
onClick={(e) => {
e.stopPropagation();
shareOrCopy(
`${baseUrl}export?id=${exportedRecording.id}`,
exportedRecording.name.replaceAll("_", " "),
);
}}
>
{t("tooltip.shareExport")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.downloadVideo")}
>
<a
download
href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`}
onClick={(e) => e.stopPropagation()}
>
<Tooltip>
<TooltipTrigger asChild>
<BlurredIconButton>
<FaDownload className="size-4" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>
{t("tooltip.downloadVideo")}
</TooltipContent>
</Tooltip>
{t("tooltip.downloadVideo")}
</a>
)}
{isAdmin && !exportedRecording.in_progress && (
<Tooltip>
<TooltipTrigger asChild>
<BlurredIconButton
onClick={() =>
setEditName({
original: exportedRecording.name,
update: undefined,
})
}
>
<MdEditSquare className="size-4" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>{t("tooltip.editName")}</TooltipContent>
</Tooltip>
</DropdownMenuItem>
{isAdmin && onAssignToCase && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.assignToCase")}
onClick={(e) => {
e.stopPropagation();
onAssignToCase(exportedRecording);
}}
>
{t("tooltip.assignToCase")}
</DropdownMenuItem>
)}
{isAdmin && (
<Tooltip>
<TooltipTrigger asChild>
<BlurredIconButton
onClick={() =>
onDelete({
file: exportedRecording.id,
exportName: exportedRecording.name,
})
}
>
<LuTrash className="size-4 fill-destructive text-destructive hover:text-white" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>{t("tooltip.deleteExport")}</TooltipContent>
</Tooltip>
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.editName")}
onClick={(e) => {
e.stopPropagation();
setEditName({
original: exportedRecording.name,
update: undefined,
});
}}
>
{t("tooltip.editName")}
</DropdownMenuItem>
)}
</div>
</div>
{!exportedRecording.in_progress && (
<Button
className="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 cursor-pointer text-white hover:bg-transparent hover:text-white"
aria-label={t("button.play", { ns: "common" })}
variant="ghost"
onClick={() => {
onSelect(exportedRecording);
}}
>
<FaPlay />
</Button>
)}
</>
{isAdmin && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.deleteExport")}
onClick={(e) => {
e.stopPropagation();
onDelete({
file: exportedRecording.id,
exportName: exportedRecording.name,
});
}}
>
{t("tooltip.deleteExport")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{loading && (
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />

View File

@@ -0,0 +1,67 @@
import { cn } from "@/lib/utils";
import {
DEFAULT_EXPORT_FILTERS,
ExportFilter,
ExportFilters,
} from "@/types/export";
import { CamerasFilterButton } from "./CamerasFilterButton";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useMemo } from "react";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
type ExportFilterGroupProps = {
className: string;
filters?: ExportFilters[];
filter?: ExportFilter;
onUpdateFilter: (filter: ExportFilter) => void;
};
export default function ExportFilterGroup({
className,
filter,
filters = DEFAULT_EXPORT_FILTERS,
onUpdateFilter,
}: ExportFilterGroupProps) {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const allowedCameras = useAllowedCameras();
const filterValues = useMemo(
() => ({
cameras: allowedCameras,
}),
[allowedCameras],
);
const groups = useMemo(() => {
if (!config) {
return [];
}
return Object.entries(config.camera_groups).sort(
(a, b) => a[1].order - b[1].order,
);
}, [config]);
return (
<div
className={cn(
"scrollbar-container flex justify-center gap-2 overflow-x-auto",
className,
)}
>
{filters.includes("cameras") && (
<CamerasFilterButton
allCameras={filterValues.cameras}
groups={groups}
selectedCameras={filter?.cameras}
hideText={false}
updateCameraFilter={(newCameras) => {
onUpdateFilter({ ...filter, cameras: newCameras });
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,166 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
type Option = {
value: string;
label: string;
};
type OptionAndInputDialogProps = {
open: boolean;
title: string;
description?: string;
options: Option[];
newValueKey: string;
initialValue?: string;
nameLabel: string;
descriptionLabel: string;
setOpen: (open: boolean) => void;
onSave: (value: string) => void;
onCreateNew: (name: string, description: string) => void;
};
export default function OptionAndInputDialog({
open,
title,
description,
options,
newValueKey,
initialValue,
nameLabel,
descriptionLabel,
setOpen,
onSave,
onCreateNew,
}: OptionAndInputDialogProps) {
const { t } = useTranslation("common");
const firstOption = useMemo(() => options[0]?.value, [options]);
const [selectedValue, setSelectedValue] = useState<string | undefined>(
initialValue ?? firstOption,
);
const [name, setName] = useState("");
const [descriptionValue, setDescriptionValue] = useState("");
useEffect(() => {
if (open) {
setSelectedValue(initialValue ?? firstOption);
setName("");
setDescriptionValue("");
}
}, [open, initialValue, firstOption]);
const isNew = selectedValue === newValueKey;
const disableSave = !selectedValue || (isNew && name.trim().length === 0);
const handleSave = () => {
if (!selectedValue) {
return;
}
const trimmedName = name.trim();
const trimmedDescription = descriptionValue.trim();
if (isNew) {
onCreateNew(trimmedName, trimmedDescription);
} else {
onSave(selectedValue);
}
setOpen(false);
};
return (
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
<DialogContent
className={cn("space-y-4", isMobile && "px-4")}
onOpenAutoFocus={(e) => {
if (isMobile) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="space-y-2">
<Select
value={selectedValue}
onValueChange={(val) => setSelectedValue(val)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isNew && (
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium text-secondary-foreground">
{nameLabel}
</label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-secondary-foreground">
{descriptionLabel}
</label>
<Input
value={descriptionValue}
onChange={(e) => setDescriptionValue(e.target.value)}
/>
</div>
</div>
)}
<DialogFooter className={cn("pt-2", isMobile && "gap-2")}>
<Button
type="button"
variant="outline"
onClick={() => {
setOpen(false);
}}
>
{t("button.cancel")}
</Button>
<Button
type="button"
variant="select"
disabled={disableSave}
onClick={handleSave}
>
{t("button.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,5 +1,5 @@
import { baseUrl } from "@/api/baseUrl";
import ExportCard from "@/components/card/ExportCard";
import { CaseCard, ExportCard } from "@/components/card/ExportCard";
import {
AlertDialog,
AlertDialogCancel,
@@ -11,64 +11,144 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import Heading from "@/components/ui/heading";
import { Input } from "@/components/ui/input";
import { Toaster } from "@/components/ui/sonner";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useHistoryBack } from "@/hooks/use-history-back";
import { useApiFilterArgs } from "@/hooks/use-api-filter";
import { cn } from "@/lib/utils";
import { DeleteClipType, Export } from "@/types/export";
import {
DeleteClipType,
Export,
ExportCase,
ExportFilter,
} from "@/types/export";
import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
import axios from "axios";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isMobile } from "react-device-detect";
import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isMobile, isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { LuFolderX } from "react-icons/lu";
import { toast } from "sonner";
import useSWR from "swr";
import ExportFilterGroup from "@/components/filter/ExportFilterGroup";
// always parse these as string arrays
const EXPORT_FILTER_ARRAY_KEYS = ["cameras"];
function Exports() {
const { t } = useTranslation(["views/exports"]);
const { data: exports, mutate } = useSWR<Export[]>("exports");
useEffect(() => {
document.title = t("documentTitle");
}, [t]);
// Filters
const [exportFilter, setExportFilter, exportSearchParams] =
useApiFilterArgs<ExportFilter>(EXPORT_FILTER_ARRAY_KEYS);
// Data
const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("cases");
const { data: rawExports, mutate: updateExports } = useSWR<Export[]>(
exportSearchParams && Object.keys(exportSearchParams).length > 0
? ["exports", exportSearchParams]
: "exports",
);
const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => {
const grouped: { [caseId: string]: Export[] } = {};
(rawExports ?? []).forEach((exp) => {
const caseId = exp.export_case || "none";
if (!grouped[caseId]) {
grouped[caseId] = [];
}
grouped[caseId].push(exp);
});
return grouped;
}, [rawExports]);
const filteredCases = useMemo<ExportCase[]>(() => {
if (!cases) {
return [];
}
return cases.filter((caseItem) => {
const caseExports = exportsByCase[caseItem.id];
return caseExports?.length;
});
}, [cases, exportsByCase]);
const exports = useMemo<Export[]>(
() => exportsByCase["none"] || [],
[exportsByCase],
);
const mutate = useCallback(() => {
updateExports();
updateCases();
}, [updateExports, updateCases]);
// Search
const [search, setSearch] = useState("");
const filteredExports = useMemo(() => {
if (!search || !exports) {
return exports;
}
return exports.filter((exp) =>
exp.name
.toLowerCase()
.replaceAll("_", " ")
.includes(search.toLowerCase()),
);
}, [exports, search]);
// Viewing
const [selected, setSelected] = useState<Export>();
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
undefined,
);
const [selectedAspect, setSelectedAspect] = useState(0.0);
// Handle browser back button to deselect case before navigating away
useHistoryBack({
enabled: true,
open: selectedCaseId !== undefined,
onClose: () => setSelectedCaseId(undefined),
});
useSearchEffect("id", (id) => {
if (!exports) {
if (!rawExports) {
return false;
}
setSelected(exports.find((exp) => exp.id == id));
setSelected(rawExports.find((exp) => exp.id == id));
return true;
});
// Deleting
useSearchEffect("caseId", (caseId: string) => {
if (!filteredCases) {
return false;
}
const exists = filteredCases.some((c) => c.id === caseId);
if (!exists) {
return false;
}
setSelectedCaseId(caseId);
return true;
});
// Modifying
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
const [exportToAssign, setExportToAssign] = useState<Export | undefined>();
const onHandleDelete = useCallback(() => {
if (!deleteClip) {
@@ -83,8 +163,6 @@ function Exports() {
});
}, [deleteClip, mutate]);
// Renaming
const onHandleRename = useCallback(
(id: string, update: string) => {
axios
@@ -107,7 +185,7 @@ function Exports() {
});
});
},
[mutate, t],
[mutate, setDeleteClip, t],
);
// Keyboard Listener
@@ -115,10 +193,27 @@ function Exports() {
const contentRef = useRef<HTMLDivElement | null>(null);
useKeyboardListener([], undefined, contentRef);
const selectedCase = useMemo(
() => filteredCases?.find((c) => c.id === selectedCaseId),
[filteredCases, selectedCaseId],
);
const resetCaseDialog = useCallback(() => {
setExportToAssign(undefined);
}, []);
return (
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
<Toaster closeButton={true} />
<CaseAssignmentDialog
exportToAssign={exportToAssign}
cases={cases}
selectedCaseId={selectedCaseId}
onClose={resetCaseDialog}
mutate={mutate}
/>
<AlertDialog
open={deleteClip != undefined}
onOpenChange={() => setDeleteClip(undefined)}
@@ -187,47 +282,360 @@ function Exports() {
</DialogContent>
</Dialog>
{exports && (
<div className="flex w-full items-center justify-center p-2">
<div
className={cn(
"flex w-full flex-col items-start space-y-2 pr-2 md:mb-2 lg:relative lg:h-10 lg:flex-row lg:items-center lg:space-y-0",
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
)}
>
<div className="w-full">
<Input
className="text-md w-full bg-muted md:w-1/3"
className="text-md w-full bg-muted md:w-1/2"
placeholder={t("search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
)}
<ExportFilterGroup
className="w-full justify-between md:justify-start lg:justify-end"
filter={exportFilter}
filters={["cameras"]}
onUpdateFilter={setExportFilter}
/>
</div>
<div className="w-full overflow-hidden">
{exports && filteredExports && filteredExports.length > 0 ? (
<div
ref={contentRef}
className="scrollbar-container grid size-full gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{Object.values(exports).map((item) => (
<ExportCard
key={item.name}
className={
search == "" || filteredExports.includes(item) ? "" : "hidden"
}
exportedRecording={item}
onSelect={setSelected}
onRename={onHandleRename}
onDelete={({ file, exportName }) =>
setDeleteClip({ file, exportName })
}
/>
))}
</div>
) : (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuFolderX className="size-16" />
{t("noExports")}
</div>
)}
{selectedCase ? (
<CaseView
contentRef={contentRef}
selectedCase={selectedCase}
exports={exportsByCase[selectedCase.id] || []}
search={search}
setSelected={setSelected}
renameClip={onHandleRename}
setDeleteClip={setDeleteClip}
onAssignToCase={setExportToAssign}
/>
) : (
<AllExportsView
contentRef={contentRef}
search={search}
cases={filteredCases}
exports={exports}
setSelectedCaseId={setSelectedCaseId}
setSelected={setSelected}
renameClip={onHandleRename}
setDeleteClip={setDeleteClip}
onAssignToCase={setExportToAssign}
/>
)}
</div>
);
}
type AllExportsViewProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
search: string;
cases?: ExportCase[];
exports: Export[];
setSelectedCaseId: (id: string) => void;
setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void;
setDeleteClip: (d: DeleteClipType | undefined) => void;
onAssignToCase: (e: Export) => void;
};
function AllExportsView({
contentRef,
search,
cases,
exports,
setSelectedCaseId,
setSelected,
renameClip,
setDeleteClip,
onAssignToCase,
}: AllExportsViewProps) {
const { t } = useTranslation(["views/exports"]);
// Filter
const filteredCases = useMemo(() => {
if (!search || !cases) {
return cases || [];
}
return cases.filter(
(caseItem) =>
caseItem.name.toLowerCase().includes(search.toLowerCase()) ||
(caseItem.description &&
caseItem.description.toLowerCase().includes(search.toLowerCase())),
);
}, [search, cases]);
const filteredExports = useMemo<Export[]>(() => {
if (!search) {
return exports;
}
return exports.filter((exp) =>
exp.name
.toLowerCase()
.replaceAll("_", " ")
.includes(search.toLowerCase()),
);
}, [exports, search]);
return (
<div className="w-full overflow-hidden">
{filteredCases?.length || filteredExports.length ? (
<div
ref={contentRef}
className="scrollbar-container flex size-full flex-col gap-4 overflow-y-auto"
>
{filteredCases.length > 0 && (
<div className="space-y-2">
<Heading as="h4">{t("headings.cases")}</Heading>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{cases?.map((item) => (
<CaseCard
key={item.name}
className={
search == "" || filteredCases?.includes(item)
? ""
: "hidden"
}
exportCase={item}
onSelect={() => {
setSelectedCaseId(item.id);
}}
/>
))}
</div>
</div>
)}
{filteredExports.length > 0 && (
<div className="space-y-4">
<Heading as="h4">{t("headings.uncategorizedExports")}</Heading>
<div
ref={contentRef}
className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{exports.map((item) => (
<ExportCard
key={item.name}
className={
search == "" || filteredExports.includes(item)
? ""
: "hidden"
}
exportedRecording={item}
onSelect={setSelected}
onRename={renameClip}
onDelete={({ file, exportName }) =>
setDeleteClip({ file, exportName })
}
onAssignToCase={onAssignToCase}
/>
))}
</div>
</div>
)}
</div>
) : (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuFolderX className="size-16" />
{t("noExports")}
</div>
)}
</div>
);
}
type CaseViewProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
selectedCase: ExportCase;
exports?: Export[];
search: string;
setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void;
setDeleteClip: (d: DeleteClipType | undefined) => void;
onAssignToCase: (e: Export) => void;
};
function CaseView({
contentRef,
selectedCase,
exports,
search,
setSelected,
renameClip,
setDeleteClip,
onAssignToCase,
}: CaseViewProps) {
const filteredExports = useMemo<Export[]>(() => {
const caseExports = (exports || []).filter(
(e) => e.export_case == selectedCase.id,
);
if (!search) {
return caseExports;
}
return caseExports.filter((exp) =>
exp.name
.toLowerCase()
.replaceAll("_", " ")
.includes(search.toLowerCase()),
);
}, [selectedCase, exports, search]);
return (
<div className="flex size-full flex-col gap-8 overflow-hidden">
<div className="flex shrink-0 flex-col gap-1">
<Heading className="capitalize" as="h2">
{selectedCase.name}
</Heading>
<div className="text-secondary-foreground">
{selectedCase.description}
</div>
</div>
<div
ref={contentRef}
className="scrollbar-container grid min-h-0 flex-1 content-start gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{exports?.map((item) => (
<ExportCard
key={item.name}
className={filteredExports.includes(item) ? "" : "hidden"}
exportedRecording={item}
onSelect={setSelected}
onRename={renameClip}
onDelete={({ file, exportName }) =>
setDeleteClip({ file, exportName })
}
onAssignToCase={onAssignToCase}
/>
))}
</div>
</div>
);
}
type CaseAssignmentDialogProps = {
exportToAssign?: Export;
cases?: ExportCase[];
selectedCaseId?: string;
onClose: () => void;
mutate: () => void;
};
function CaseAssignmentDialog({
exportToAssign,
cases,
selectedCaseId,
onClose,
mutate,
}: CaseAssignmentDialogProps) {
const { t } = useTranslation(["views/exports"]);
const caseOptions = useMemo(
() => [
...(cases ?? [])
.map((c) => ({
value: c.id,
label: c.name,
}))
.sort((cA, cB) => cA.label.localeCompare(cB.label)),
{
value: "new",
label: t("caseDialog.newCaseOption"),
},
],
[cases, t],
);
const handleSave = useCallback(
async (caseId: string) => {
if (!exportToAssign) return;
try {
await axios.patch(`export/${exportToAssign.id}/case`, {
export_case_id: caseId,
});
mutate();
onClose();
} catch (error: unknown) {
const apiError = error as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
apiError.response?.data?.message ||
apiError.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
position: "top-center",
});
}
},
[exportToAssign, mutate, onClose, t],
);
const handleCreateNew = useCallback(
async (name: string, description: string) => {
if (!exportToAssign) return;
try {
const createResp = await axios.post("cases", {
name,
description,
});
const newCaseId: string | undefined = createResp.data?.id;
if (newCaseId) {
await axios.patch(`export/${exportToAssign.id}/case`, {
export_case_id: newCaseId,
});
}
mutate();
onClose();
} catch (error: unknown) {
const apiError = error as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
apiError.response?.data?.message ||
apiError.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
position: "top-center",
});
}
},
[exportToAssign, mutate, onClose, t],
);
if (!exportToAssign) {
return null;
}
return (
<OptionAndInputDialog
open={!!exportToAssign}
title={t("caseDialog.title")}
description={t("caseDialog.description")}
setOpen={(open) => {
if (!open) {
onClose();
}
}}
options={caseOptions}
nameLabel={t("caseDialog.nameLabel")}
descriptionLabel={t("caseDialog.descriptionLabel")}
initialValue={selectedCaseId}
newValueKey="new"
onSave={handleSave}
onCreateNew={handleCreateNew}
/>
);
}
export default Exports;

View File

@@ -6,9 +6,28 @@ export type Export = {
video_path: string;
thumb_path: string;
in_progress: boolean;
export_case?: string;
};
export type ExportCase = {
id: string;
name: string;
description: string;
created_at: number;
updated_at: number;
};
export type DeleteClipType = {
file: string;
exportName: string;
};
// filtering
const EXPORT_FILTERS = ["cameras"] as const;
export type ExportFilters = (typeof EXPORT_FILTERS)[number];
export const DEFAULT_EXPORT_FILTERS: ExportFilters[] = ["cameras"];
export type ExportFilter = {
cameras?: string[];
};

View File

@@ -24,6 +24,10 @@ export type CameraStats = {
pid: number;
process_fps: number;
skipped_fps: number;
connection_quality: "excellent" | "fair" | "poor" | "unusable";
expected_fps: number;
reconnects_last_hour: number;
stalls_last_hour: number;
};
export type CpuStats = {
@@ -37,6 +41,7 @@ export type DetectorStats = {
detection_start: number;
inference_speed: number;
pid: number;
temperature?: number;
};
export type EmbeddingsStats = {
@@ -68,7 +73,6 @@ export type GpuInfo = "vainfo" | "nvinfo";
export type ServiceStats = {
last_updated: number;
storage: { [path: string]: StorageStats };
temperatures: { [apex: string]: number };
uptime: number;
latest_version: string;
version: string;

View File

@@ -1,6 +1,7 @@
import { useFrigateStats } from "@/api/ws";
import { CameraLineGraph } from "@/components/graph/LineGraph";
import CameraInfoDialog from "@/components/overlay/CameraInfoDialog";
import { ConnectionQualityIndicator } from "@/components/camera/ConnectionQualityIndicator";
import { Skeleton } from "@/components/ui/skeleton";
import { FrigateConfig } from "@/types/frigateConfig";
import { FrigateStats } from "@/types/stats";
@@ -282,8 +283,37 @@ export default function CameraMetrics({
)}
<div className="flex w-full flex-col gap-3">
<div className="flex flex-row items-center justify-between">
<div className="text-sm font-medium text-muted-foreground smart-capitalize">
<CameraNameLabel camera={camera} />
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-muted-foreground smart-capitalize">
<CameraNameLabel camera={camera} />
</div>
{statsHistory.length > 0 &&
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
] && (
<ConnectionQualityIndicator
quality={
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
]?.connection_quality
}
expectedFps={
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
]?.expected_fps || 0
}
reconnects={
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
]?.reconnects_last_hour || 0
}
stalls={
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
]?.stalls_last_hour || 0
}
/>
)}
</div>
<Tooltip>
<TooltipTrigger>

View File

@@ -127,13 +127,6 @@ export default function GeneralMetrics({
return undefined;
}
if (
statsHistory.length > 0 &&
Object.keys(statsHistory[0].service.temperatures).length == 0
) {
return undefined;
}
const series: {
[key: string]: { name: string; data: { x: number; y: number }[] };
} = {};
@@ -143,22 +136,22 @@ export default function GeneralMetrics({
return;
}
Object.entries(stats.detectors).forEach(([key], cIdx) => {
if (!key.includes("coral")) {
Object.entries(stats.detectors).forEach(([key, detectorStats]) => {
if (detectorStats.temperature === undefined) {
return;
}
if (cIdx <= Object.keys(stats.service.temperatures).length) {
if (!(key in series)) {
series[key] = {
name: key,
data: [],
};
}
const temp = Object.values(stats.service.temperatures)[cIdx];
series[key].data.push({ x: statsIdx + 1, y: Math.round(temp) });
if (!(key in series)) {
series[key] = {
name: key,
data: [],
};
}
series[key].data.push({
x: statsIdx + 1,
y: Math.round(detectorStats.temperature),
});
});
});

View File

@@ -4,7 +4,7 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import monacoEditorPlugin from "vite-plugin-monaco-editor";
const proxyHost = process.env.PROXY_HOST || "localhost:5000";
const proxyHost = process.env.PROXY_HOST || "1ocalhost:5000";
// https://vitejs.dev/config/
export default defineConfig({