Compare commits

..

No commits in common. "f0a6626c6a3cf64eefad391ad568a26efe81f7fa" and "29ca18c24cc076bc4e6052853d297589e61d144e" have entirely different histories.

58 changed files with 340 additions and 1221 deletions

View File

@ -54,28 +54,6 @@ class CameraState:
self.ptz_autotracker_thread = ptz_autotracker_thread self.ptz_autotracker_thread = ptz_autotracker_thread
self.prev_enabled = self.camera_config.enabled self.prev_enabled = self.camera_config.enabled
# Minimum object area thresholds for fast-tracking updates to secondary
# face/LPR pipelines when using a model without built-in detection.
self.face_recognition_min_obj_area: int = 0
self.lpr_min_obj_area: int = 0
if (
self.camera_config.face_recognition.enabled
and "face" not in config.objects.all_objects
):
# A face is roughly 1/8 of person box area; use a conservative
# multiplier so fast-tracking starts slightly before the optimal zone
self.face_recognition_min_obj_area = (
self.camera_config.face_recognition.min_area * 6
)
if (
self.camera_config.lpr.enabled
and "license_plate" not in self.camera_config.objects.track
):
# A plate is a smaller fraction of a vehicle box; use ~20x multiplier
self.lpr_min_obj_area = self.camera_config.lpr.min_area * 20
def get_current_frame(self, draw_options: dict[str, Any] = {}) -> np.ndarray: def get_current_frame(self, draw_options: dict[str, Any] = {}) -> np.ndarray:
with self.current_frame_lock: with self.current_frame_lock:
frame_copy = np.copy(self._current_frame) frame_copy = np.copy(self._current_frame)
@ -394,30 +372,13 @@ class CameraState:
updated_obj.last_updated = frame_time updated_obj.last_updated = frame_time
# Determine the staleness threshold for publishing updates. # if it has been more than 5 seconds since the last thumb update
# Fast-track to 1s for objects in the optimal size range for # and the last update is greater than the last publish or
# secondary face/LPR recognition that don't yet have a sub_label. # the object has changed significantly or
obj_area = updated_obj.obj_data.get("area", 0) # the object moved enough to update the path
obj_label = updated_obj.obj_data.get("label")
publish_threshold = 5
if (
obj_label == "person"
and self.face_recognition_min_obj_area > 0
and obj_area >= self.face_recognition_min_obj_area
and updated_obj.obj_data.get("sub_label") is None
) or (
obj_label in ("car", "motorcycle")
and self.lpr_min_obj_area > 0
and obj_area >= self.lpr_min_obj_area
and updated_obj.obj_data.get("sub_label") is None
and updated_obj.obj_data.get("recognized_license_plate") is None
):
publish_threshold = 1
if ( if (
( (
frame_time - updated_obj.last_published > publish_threshold frame_time - updated_obj.last_published > 5
and updated_obj.last_updated > updated_obj.last_published and updated_obj.last_updated > updated_obj.last_published
) )
or significant_update or significant_update
@ -428,18 +389,6 @@ class CameraState:
c(self.name, updated_obj, frame_name) c(self.name, updated_obj, frame_name)
updated_obj.last_published = frame_time updated_obj.last_published = frame_time
# send MQTT snapshot when object first enters a required zone,
# since the initial snapshot at creation time is blocked before
# zone evaluation has run
if updated_obj.new_zone_entered and not updated_obj.false_positive:
mqtt_required = self.camera_config.mqtt.required_zones
if mqtt_required and set(updated_obj.entered_zones) & set(
mqtt_required
):
object_type = updated_obj.obj_data["label"]
self.send_mqtt_snapshot(updated_obj, object_type)
updated_obj.new_zone_entered = False
for id in removed_ids: for id in removed_ids:
# publish events to mqtt # publish events to mqtt
removed_obj = tracked_objects[id] removed_obj = tracked_objects[id]

View File

@ -1095,7 +1095,7 @@ class LicensePlateProcessingMixin:
logger.debug( logger.debug(
f"{camera}: Found license plate. Bounding box: {expanded_box.astype(int)}" f"{camera}: Found license plate. Bounding box: {expanded_box.astype(int)}"
) )
return tuple(int(x) for x in expanded_box) # type: ignore[return-value] return tuple(expanded_box.astype(int)) # type: ignore[return-value]
else: else:
return None # No detection above the threshold return None # No detection above the threshold

View File

@ -149,13 +149,10 @@ class ReviewDescriptionProcessor(PostProcessorApi):
additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2 additional_buffer_per_side = (MIN_RECORDING_DURATION - duration) / 2
buffer_extension = min(5, additional_buffer_per_side) buffer_extension = min(5, additional_buffer_per_side)
final_data["start_time"] -= buffer_extension
final_data["end_time"] += buffer_extension
thumbs = self.get_recording_frames( thumbs = self.get_recording_frames(
camera, camera,
final_data["start_time"], final_data["start_time"] - buffer_extension,
final_data["end_time"], final_data["end_time"] + buffer_extension,
height=480, # Use 480p for good balance between quality and token usage height=480, # Use 480p for good balance between quality and token usage
) )

View File

@ -249,9 +249,10 @@ class PreviewRecorder:
"v2": v2, "v2": v2,
} }
# end segment at end of hour (use UTC to avoid DST issues) # end segment at end of hour
self.segment_end = ( self.segment_end = (
(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1)) (datetime.datetime.now() + datetime.timedelta(hours=1))
.astimezone(datetime.timezone.utc)
.replace(minute=0, second=0, microsecond=0) .replace(minute=0, second=0, microsecond=0)
.timestamp() .timestamp()
) )
@ -263,7 +264,8 @@ class PreviewRecorder:
# check for existing items in cache # check for existing items in cache
start_ts = ( start_ts = (
datetime.datetime.now(datetime.timezone.utc) datetime.datetime.now()
.astimezone(datetime.timezone.utc)
.replace(minute=0, second=0, microsecond=0) .replace(minute=0, second=0, microsecond=0)
.timestamp() .timestamp()
) )
@ -297,10 +299,8 @@ class PreviewRecorder:
def reset_frame_cache(self, frame_time: float) -> None: def reset_frame_cache(self, frame_time: float) -> None:
self.segment_end = ( self.segment_end = (
( (datetime.datetime.now() + datetime.timedelta(hours=1))
datetime.datetime.fromtimestamp(frame_time, tz=datetime.timezone.utc) .astimezone(datetime.timezone.utc)
+ datetime.timedelta(hours=1)
)
.replace(minute=0, second=0, microsecond=0) .replace(minute=0, second=0, microsecond=0)
.timestamp() .timestamp()
) )

View File

@ -52,66 +52,18 @@ class StatsEmitter(threading.Thread):
def get_stats_history( def get_stats_history(
self, keys: Optional[list[str]] = None self, keys: Optional[list[str]] = None
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Get stats history. """Get stats history."""
Supports dot-notation for nested keys to avoid returning large objects
when only specific subfields are needed. Handles two patterns:
- Flat dict: "service.last_updated" returns {"service": {"last_updated": ...}}
- Dict-of-dicts: "cameras.camera_fps" returns each camera entry filtered
to only include "camera_fps"
"""
if not keys: if not keys:
return self.stats_history return self.stats_history
# Pre-parse keys into top-level keys and dot-notation fields
top_level_keys: list[str] = []
nested_keys: dict[str, list[str]] = {}
for k in keys:
if "." in k:
parent_key, child_key = k.split(".", 1)
nested_keys.setdefault(parent_key, []).append(child_key)
else:
top_level_keys.append(k)
selected_stats: list[dict[str, Any]] = [] selected_stats: list[dict[str, Any]] = []
for s in self.stats_history: for s in self.stats_history:
selected: dict[str, Any] = {} selected = {}
for k in top_level_keys: for k in keys:
selected[k] = s.get(k) selected[k] = s.get(k)
for parent_key, child_keys in nested_keys.items():
parent = s.get(parent_key)
if not isinstance(parent, dict):
selected[parent_key] = parent
continue
# Check if values are dicts (dict-of-dicts like cameras/detectors)
first_value = next(iter(parent.values()), None)
if isinstance(first_value, dict):
# Filter each nested entry to only requested fields,
# omitting None values to preserve key-absence semantics
selected[parent_key] = {
entry_key: {
field: val
for field in child_keys
if (val := entry.get(field)) is not None
}
for entry_key, entry in parent.items()
}
else:
# Flat dict (like service) - pick individual fields
if parent_key not in selected:
selected[parent_key] = {}
for child_key in child_keys:
selected[parent_key][child_key] = parent.get(child_key)
selected_stats.append(selected) selected_stats.append(selected)
return selected_stats return selected_stats

View File

@ -355,37 +355,16 @@ class CustomCollector(object):
gpu_mem_usages = GaugeMetricFamily( gpu_mem_usages = GaugeMetricFamily(
"frigate_gpu_mem_usage_percent", "GPU memory usage %", labels=["gpu_name"] "frigate_gpu_mem_usage_percent", "GPU memory usage %", labels=["gpu_name"]
) )
gpu_enc_usages = GaugeMetricFamily(
"frigate_gpu_encoder_usage_percent",
"GPU encoder utilisation %",
labels=["gpu_name"],
)
gpu_compute_usages = GaugeMetricFamily(
"frigate_gpu_compute_usage_percent",
"GPU compute / encode utilisation %",
labels=["gpu_name"],
)
gpu_dec_usages = GaugeMetricFamily(
"frigate_gpu_decoder_usage_percent",
"GPU decoder utilisation %",
labels=["gpu_name"],
)
try: try:
for gpu_name, gpu_stats in stats["gpu_usages"].items(): for gpu_name, gpu_stats in stats["gpu_usages"].items():
self.add_metric(gpu_usages, [gpu_name], gpu_stats, "gpu") self.add_metric(gpu_usages, [gpu_name], gpu_stats, "gpu")
self.add_metric(gpu_mem_usages, [gpu_name], gpu_stats, "mem") self.add_metric(gpu_mem_usages, [gpu_name], gpu_stats, "mem")
self.add_metric(gpu_enc_usages, [gpu_name], gpu_stats, "enc")
self.add_metric(gpu_compute_usages, [gpu_name], gpu_stats, "compute")
self.add_metric(gpu_dec_usages, [gpu_name], gpu_stats, "dec")
except KeyError: except KeyError:
pass pass
yield gpu_usages yield gpu_usages
yield gpu_mem_usages yield gpu_mem_usages
yield gpu_enc_usages
yield gpu_compute_usages
yield gpu_dec_usages
# service stats # service stats
uptime_seconds = GaugeMetricFamily( uptime_seconds = GaugeMetricFamily(

View File

@ -261,33 +261,45 @@ async def set_gpu_stats(
else: else:
stats["jetson-gpu"] = {"gpu": "", "mem": ""} stats["jetson-gpu"] = {"gpu": "", "mem": ""}
hwaccel_errors.append(args) hwaccel_errors.append(args)
elif "qsv" in args or ("vaapi" in args and not is_vaapi_amd_driver()): elif "qsv" in args:
if not config.telemetry.stats.intel_gpu_stats: if not config.telemetry.stats.intel_gpu_stats:
continue continue
if "intel-gpu" not in stats: # intel QSV GPU
# intel GPU (QSV or VAAPI both use the same physical GPU) intel_usage = get_intel_gpu_stats(config.telemetry.stats.intel_gpu_device)
if intel_usage is not None:
stats["intel-qsv"] = intel_usage or {"gpu": "", "mem": ""}
else:
stats["intel-qsv"] = {"gpu": "", "mem": ""}
hwaccel_errors.append(args)
elif "vaapi" in args:
if is_vaapi_amd_driver():
if not config.telemetry.stats.amd_gpu_stats:
continue
# AMD VAAPI GPU
amd_usage = get_amd_gpu_stats()
if amd_usage:
stats["amd-vaapi"] = amd_usage
else:
stats["amd-vaapi"] = {"gpu": "", "mem": ""}
hwaccel_errors.append(args)
else:
if not config.telemetry.stats.intel_gpu_stats:
continue
# intel VAAPI GPU
intel_usage = get_intel_gpu_stats( intel_usage = get_intel_gpu_stats(
config.telemetry.stats.intel_gpu_device config.telemetry.stats.intel_gpu_device
) )
if intel_usage is not None: if intel_usage is not None:
stats["intel-gpu"] = intel_usage or {"gpu": "", "mem": ""} stats["intel-vaapi"] = intel_usage or {"gpu": "", "mem": ""}
else: else:
stats["intel-gpu"] = {"gpu": "", "mem": ""} stats["intel-vaapi"] = {"gpu": "", "mem": ""}
hwaccel_errors.append(args) hwaccel_errors.append(args)
elif "vaapi" in args:
if not config.telemetry.stats.amd_gpu_stats:
continue
# AMD VAAPI GPU
amd_usage = get_amd_gpu_stats()
if amd_usage:
stats["amd-vaapi"] = amd_usage
else:
stats["amd-vaapi"] = {"gpu": "", "mem": ""}
hwaccel_errors.append(args)
elif "preset-rk" in args: elif "preset-rk" in args:
rga_usage = get_rockchip_gpu_stats() rga_usage = get_rockchip_gpu_stats()
@ -498,30 +510,4 @@ def stats_snapshot(
"pid": pid, "pid": pid,
} }
# Embed cpu/mem stats into detectors, cameras, and processes
# so history consumers don't need the full cpu_usages dict
cpu_usages = stats.get("cpu_usages", {})
for det_stats in stats["detectors"].values():
pid_str = str(det_stats.get("pid", ""))
usage = cpu_usages.get(pid_str, {})
det_stats["cpu"] = usage.get("cpu")
det_stats["mem"] = usage.get("mem")
for cam_stats in stats["cameras"].values():
for pid_key, field in [
("ffmpeg_pid", "ffmpeg_cpu"),
("capture_pid", "capture_cpu"),
("pid", "detect_cpu"),
]:
pid_str = str(cam_stats.get(pid_key, ""))
usage = cpu_usages.get(pid_str, {})
cam_stats[field] = usage.get("cpu")
for proc_stats in stats["processes"].values():
pid_str = str(proc_stats.get("pid", ""))
usage = cpu_usages.get(pid_str, {})
proc_stats["cpu"] = usage.get("cpu")
proc_stats["mem"] = usage.get("mem")
return stats return stats

View File

@ -39,12 +39,8 @@ class TestGpuStats(unittest.TestCase):
process.stdout = self.intel_results process.stdout = self.intel_results
sp.return_value = process sp.return_value = process
intel_stats = get_intel_gpu_stats(False) intel_stats = get_intel_gpu_stats(False)
# rc6 values: 47.844741 and 100.0 → avg 73.92 → gpu = 100 - 73.92 = 26.08% print(f"the intel stats are {intel_stats}")
# Render/3D/0: 0.0 and 0.0 → enc = 0.0%
# Video/0: 4.533124 and 0.0 → dec = 2.27%
assert intel_stats == { assert intel_stats == {
"gpu": "26.08%", "gpu": "1.13%",
"mem": "-%", "mem": "-%",
"compute": "0.0%",
"dec": "2.27%",
} }

View File

@ -61,7 +61,6 @@ class TrackedObject:
self.zone_loitering: dict[str, int] = {} self.zone_loitering: dict[str, int] = {}
self.current_zones: list[str] = [] self.current_zones: list[str] = []
self.entered_zones: list[str] = [] self.entered_zones: list[str] = []
self.new_zone_entered: bool = False
self.attributes: dict[str, float] = defaultdict(float) self.attributes: dict[str, float] = defaultdict(float)
self.false_positive = True self.false_positive = True
self.has_clip = False self.has_clip = False
@ -279,7 +278,6 @@ class TrackedObject:
if name not in self.entered_zones: if name not in self.entered_zones:
self.entered_zones.append(name) self.entered_zones.append(name)
self.new_zone_entered = True
else: else:
self.zone_loitering[name] = loitering_score self.zone_loitering[name] = loitering_score

View File

@ -265,30 +265,14 @@ def get_amd_gpu_stats() -> Optional[dict[str, str]]:
def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, str]]: def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, str]]:
"""Get stats using intel_gpu_top. """Get stats using intel_gpu_top."""
Returns overall GPU usage derived from rc6 residency (idle time),
plus individual engine breakdowns:
- enc: Render/3D engine (compute/shader encoder, used by QSV)
- dec: Video engines (fixed-function codec, used by VAAPI)
"""
def get_stats_manually(output: str) -> dict[str, str]: def get_stats_manually(output: str) -> dict[str, str]:
"""Find global stats via regex when json fails to parse.""" """Find global stats via regex when json fails to parse."""
reading = "".join(output) reading = "".join(output)
results: dict[str, str] = {} results: dict[str, str] = {}
# rc6 residency for overall GPU usage # render is used for qsv
rc6_match = re.search(r'"rc6":\{"value":([\d.]+)', reading)
if rc6_match:
rc6_value = float(rc6_match.group(1))
results["gpu"] = f"{round(100.0 - rc6_value, 2)}%"
else:
results["gpu"] = "-%"
results["mem"] = "-%"
# Render/3D is the compute/encode engine
render = [] render = []
for result in re.findall(r'"Render/3D/0":{[a-z":\d.,%]+}', reading): for result in re.findall(r'"Render/3D/0":{[a-z":\d.,%]+}', reading):
packet = json.loads(result[14:]) packet = json.loads(result[14:])
@ -296,9 +280,11 @@ def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, s
render.append(float(single)) render.append(float(single))
if render: if render:
results["compute"] = f"{round(sum(render) / len(render), 2)}%" render_avg = sum(render) / len(render)
else:
render_avg = 1
# Video engines are the fixed-function decode engines # video is used for vaapi
video = [] video = []
for result in re.findall(r'"Video/\d":{[a-z":\d.,%]+}', reading): for result in re.findall(r'"Video/\d":{[a-z":\d.,%]+}', reading):
packet = json.loads(result[10:]) packet = json.loads(result[10:])
@ -306,8 +292,12 @@ def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, s
video.append(float(single)) video.append(float(single))
if video: if video:
results["dec"] = f"{round(sum(video) / len(video), 2)}%" video_avg = sum(video) / len(video)
else:
video_avg = 1
results["gpu"] = f"{round((video_avg + render_avg) / 2, 2)}%"
results["mem"] = "-%"
return results return results
intel_gpu_top_command = [ intel_gpu_top_command = [
@ -346,18 +336,10 @@ def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, s
return get_stats_manually(output) return get_stats_manually(output)
results: dict[str, str] = {} results: dict[str, str] = {}
rc6_values = [] render = {"global": []}
render_global = [] video = {"global": []}
video_global = []
# per-client: {pid: [total_busy_per_sample, ...]}
client_usages: dict[str, list[float]] = {}
for block in data: for block in data:
# rc6 residency: percentage of time GPU is idle
rc6 = block.get("rc6", {}).get("value")
if rc6 is not None:
rc6_values.append(float(rc6))
global_engine = block.get("engines") global_engine = block.get("engines")
if global_engine: if global_engine:
@ -365,53 +347,48 @@ def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, s
video_frame = global_engine.get("Video/0", {}).get("busy") video_frame = global_engine.get("Video/0", {}).get("busy")
if render_frame is not None: if render_frame is not None:
render_global.append(float(render_frame)) render["global"].append(float(render_frame))
if video_frame is not None: if video_frame is not None:
video_global.append(float(video_frame)) video["global"].append(float(video_frame))
clients = block.get("clients", {}) clients = block.get("clients", {})
if clients: if clients and len(clients):
for client_block in clients.values(): for client_block in clients.values():
pid = client_block["pid"] key = client_block["pid"]
if pid not in client_usages: if render.get(key) is None:
client_usages[pid] = [] render[key] = []
video[key] = []
# Sum all engine-class busy values for this client client_engine = client_block.get("engine-classes", {})
total_busy = 0.0
for engine in client_block.get("engine-classes", {}).values():
busy = engine.get("busy")
if busy is not None:
total_busy += float(busy)
client_usages[pid].append(total_busy) render_frame = client_engine.get("Render/3D", {}).get("busy")
video_frame = client_engine.get("Video", {}).get("busy")
# Overall GPU usage from rc6 (idle) residency if render_frame is not None:
if rc6_values: render[key].append(float(render_frame))
rc6_avg = sum(rc6_values) / len(rc6_values)
results["gpu"] = f"{round(100.0 - rc6_avg, 2)}%"
results["mem"] = "-%" if video_frame is not None:
video[key].append(float(video_frame))
# Compute: Render/3D engine (compute/shader workloads and QSV encode) if render["global"] and video["global"]:
if render_global: results["gpu"] = (
results["compute"] = f"{round(sum(render_global) / len(render_global), 2)}%" f"{round(((sum(render['global']) / len(render['global'])) + (sum(video['global']) / len(video['global']))) / 2, 2)}%"
)
results["mem"] = "-%"
# Decoder: Video engine (fixed-function codec) if len(render.keys()) > 1:
if video_global:
results["dec"] = f"{round(sum(video_global) / len(video_global), 2)}%"
# Per-client GPU usage (sum of all engines per process)
if client_usages:
results["clients"] = {} results["clients"] = {}
for pid, samples in client_usages.items(): for key in render.keys():
if samples: if key == "global" or not render[key] or not video[key]:
results["clients"][pid] = ( continue
f"{round(sum(samples) / len(samples), 2)}%"
) results["clients"][key] = (
f"{round(((sum(render[key]) / len(render[key])) + (sum(video[key]) / len(video[key]))) / 2, 2)}%"
)
return results return results

View File

@ -1433,7 +1433,8 @@
}, },
"reviewLabels": { "reviewLabels": {
"summary": "{{count}} labels selected", "summary": "{{count}} labels selected",
"empty": "No labels available" "empty": "No labels available",
"allNonAlertDetections": "All non-alert activity will be included as detections."
}, },
"filters": { "filters": {
"objectFieldLabel": "{{field}} for {{label}}" "objectFieldLabel": "{{field}} for {{label}}"
@ -1605,38 +1606,5 @@
"onvif": { "onvif": {
"profileAuto": "Auto", "profileAuto": "Auto",
"profileLoading": "Loading profiles..." "profileLoading": "Loading profiles..."
},
"configMessages": {
"review": {
"recordDisabled": "Recording is disabled, review items will not be generated.",
"detectDisabled": "Object detection is disabled. Review items require detected objects to categorize alerts and detections.",
"allNonAlertDetections": "All non-alert activity will be included as detections."
},
"audio": {
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
},
"audioTranscription": {
"audioDetectionDisabled": "Audio detection is not enabled for this camera. Audio transcription requires audio detection to be active."
},
"detect": {
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended."
},
"faceRecognition": {
"globalDisabled": "Face recognition is not enabled at the global level. Enable it in global settings for camera-level face recognition to function.",
"personNotTracked": "Face recognition requires the 'person' object to be tracked. Ensure 'person' is in the object tracking list."
},
"lpr": {
"globalDisabled": "License plate recognition is not enabled at the global level. Enable it in global settings for camera-level LPR to function.",
"vehicleNotTracked": "License plate recognition requires 'car' or 'motorcycle' to be tracked."
},
"record": {
"noRecordRole": "No streams have the record role defined. Recording will not function."
},
"birdseye": {
"objectsModeDetectDisabled": "Birdseye is set to 'objects' mode, but object detection is disabled for this camera. The camera will not appear in Birdseye."
},
"snapshots": {
"detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created."
}
} }
} }

View File

@ -78,7 +78,6 @@
"gpuUsage": "GPU Usage", "gpuUsage": "GPU Usage",
"gpuMemory": "GPU Memory", "gpuMemory": "GPU Memory",
"gpuEncoder": "GPU Encoder", "gpuEncoder": "GPU Encoder",
"gpuCompute": "GPU Compute / Encode",
"gpuDecoder": "GPU Decoder", "gpuDecoder": "GPU Decoder",
"gpuTemperature": "GPU Temperature", "gpuTemperature": "GPU Temperature",
"gpuInfo": { "gpuInfo": {
@ -189,7 +188,6 @@
"cameraFfmpeg": "{{camName}} FFmpeg", "cameraFfmpeg": "{{camName}} FFmpeg",
"cameraCapture": "{{camName}} capture", "cameraCapture": "{{camName}} capture",
"cameraDetect": "{{camName}} detect", "cameraDetect": "{{camName}} detect",
"cameraGpu": "{{camName}} GPU",
"cameraFramesPerSecond": "{{camName}} frames per second", "cameraFramesPerSecond": "{{camName}} frames per second",
"cameraDetectionsPerSecond": "{{camName}} detections per second", "cameraDetectionsPerSecond": "{{camName}} detections per second",
"cameraSkippedDetectionsPerSecond": "{{camName}} skipped detections per second" "cameraSkippedDetectionsPerSecond": "{{camName}} skipped detections per second"

View File

@ -116,7 +116,8 @@ export default function Statusbar() {
case "amd-vaapi": case "amd-vaapi":
gpuTitle = "AMD GPU"; gpuTitle = "AMD GPU";
break; break;
case "intel-gpu": case "intel-vaapi":
case "intel-qsv":
gpuTitle = "Intel GPU"; gpuTitle = "Intel GPU";
break; break;
case "rockchip": case "rockchip":

View File

@ -8,7 +8,6 @@ import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTimeFormat } from "@/hooks/use-date-utils";
const GRAPH_COLORS = ["#3b82f6", "#ef4444"]; // RMS, dBFS const GRAPH_COLORS = ["#3b82f6", "#ef4444"]; // RMS, dBFS
@ -73,7 +72,7 @@ export function AudioLevelGraph({ cameraName }: AudioLevelGraphProps) {
return [last.rms, last.dBFS]; return [last.rms, last.dBFS];
}, [audioData]); }, [audioData]);
const timeFormat = useTimeFormat(config); const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
const formatString = useMemo( const formatString = useMemo(
() => () =>
t(`time.formattedTimestampHourMinuteSecond.${timeFormat}`, { t(`time.formattedTimestampHourMinuteSecond.${timeFormat}`, {

View File

@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useDateLocale } from "@/hooks/use-date-locale"; import { useDateLocale } from "@/hooks/use-date-locale";
import { useTimeFormat } from "@/hooks/use-date-utils";
import { useMemo } from "react"; import { useMemo } from "react";
type DownloadVideoButtonProps = { type DownloadVideoButtonProps = {
@ -27,7 +26,7 @@ export function DownloadVideoButton({
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const locale = useDateLocale(); const locale = useDateLocale();
const timeFormat = useTimeFormat(config); const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
const format = useMemo(() => { const format = useMemo(() => {
return t(`time.formattedTimestampFilename.${timeFormat}`, { ns: "common" }); return t(`time.formattedTimestampFilename.${timeFormat}`, { ns: "common" });
}, [t, timeFormat]); }, [t, timeFormat]);

View File

@ -1,5 +1,5 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils"; import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { REVIEW_PADDING, ReviewSegment } from "@/types/review"; import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
@ -55,10 +55,9 @@ export default function ReviewCard({
const { t } = useTranslation(["components/dialog"]); const { t } = useTranslation(["components/dialog"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
const is24Hour = use24HourTime(config);
const formattedDate = useFormattedTimestamp( const formattedDate = useFormattedTimestamp(
event.start_time, event.start_time,
is24Hour config?.ui.time_format == "24hour"
? t("time.formattedTimestampHourMinute.24hour", { ns: "common" }) ? t("time.formattedTimestampHourMinute.24hour", { ns: "common" })
: t("time.formattedTimestampHourMinute.12hour", { ns: "common" }), : t("time.formattedTimestampHourMinute.12hour", { ns: "common" }),
config?.ui.timezone, config?.ui.timezone,

View File

@ -1,7 +1,7 @@
import TimeAgo from "../dynamic/TimeAgo"; import TimeAgo from "../dynamic/TimeAgo";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils"; import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { SearchResult } from "@/types/search"; import { SearchResult } from "@/types/search";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import SearchResultActions from "../menu/SearchResultActions"; import SearchResultActions from "../menu/SearchResultActions";
@ -29,10 +29,9 @@ export default function SearchThumbnailFooter({
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
// date // date
const is24Hour = use24HourTime(config);
const formattedDate = useFormattedTimestamp( const formattedDate = useFormattedTimestamp(
searchResult.start_time, searchResult.start_time,
is24Hour config?.ui.time_format == "24hour"
? t("time.formattedTimestampMonthDayHourMinute.24hour", { ns: "common" }) ? t("time.formattedTimestampMonthDayHourMinute.24hour", { ns: "common" })
: t("time.formattedTimestampMonthDayHourMinute.12hour", { ns: "common" }), : t("time.formattedTimestampMonthDayHourMinute.12hour", { ns: "common" }),
config?.ui.timezone, config?.ui.timezone,

View File

@ -1,48 +0,0 @@
import { useTranslation } from "react-i18next";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { LuInfo, LuTriangleAlert, LuCircleAlert } from "react-icons/lu";
import type { MessageSeverity } from "./section-configs/types";
const severityVariantMap: Record<
MessageSeverity,
"info" | "warning" | "destructive"
> = {
info: "info",
warning: "warning",
error: "destructive",
};
function SeverityIcon({ severity }: { severity: string }) {
switch (severity) {
case "info":
return <LuInfo className="size-4" />;
case "warning":
return <LuTriangleAlert className="size-4" />;
case "error":
return <LuCircleAlert className="size-4" />;
default:
return <LuInfo className="size-4" />;
}
}
type ConfigFieldMessageProps = {
messageKey: string;
severity: string;
};
export function ConfigFieldMessage({
messageKey,
severity,
}: ConfigFieldMessageProps) {
const { t } = useTranslation("views/settings");
return (
<Alert
variant={severityVariantMap[severity as MessageSeverity] ?? "info"}
className="flex items-center [&>svg+div]:translate-y-0 [&>svg]:static [&>svg~*]:pl-2"
>
<SeverityIcon severity={severity} />
<AlertDescription>{t(messageKey)}</AlertDescription>
</Alert>
);
}

View File

@ -1,52 +0,0 @@
import { useTranslation } from "react-i18next";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { LuInfo, LuTriangleAlert, LuCircleAlert } from "react-icons/lu";
import type {
ConditionalMessage,
MessageSeverity,
} from "./section-configs/types";
const severityVariantMap: Record<
MessageSeverity,
"info" | "warning" | "destructive"
> = {
info: "info",
warning: "warning",
error: "destructive",
};
function SeverityIcon({ severity }: { severity: MessageSeverity }) {
switch (severity) {
case "info":
return <LuInfo className="size-4" />;
case "warning":
return <LuTriangleAlert className="size-4" />;
case "error":
return <LuCircleAlert className="size-4" />;
}
}
type ConfigMessageBannerProps = {
messages: ConditionalMessage[];
};
export function ConfigMessageBanner({ messages }: ConfigMessageBannerProps) {
const { t } = useTranslation("views/settings");
if (messages.length === 0) return null;
return (
<div className="max-w-5xl space-y-2">
{messages.map((msg) => (
<Alert
key={msg.key}
variant={severityVariantMap[msg.severity]}
className="flex items-center [&>svg+div]:translate-y-0 [&>svg]:static [&>svg~*]:pl-2"
>
<SeverityIcon severity={msg.severity} />
<AlertDescription>{t(msg.messageKey)}</AlertDescription>
</Alert>
))}
</div>
);
}

View File

@ -3,21 +3,6 @@ import type { SectionConfigOverrides } from "./types";
const audio: SectionConfigOverrides = { const audio: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/audio_detectors", sectionDocs: "/configuration/audio_detectors",
messages: [
{
key: "no-audio-role",
messageKey: "configMessages.audio.noAudioRole",
severity: "warning",
condition: (ctx) => {
if (ctx.level === "camera" && ctx.fullCameraConfig) {
return !ctx.fullCameraConfig.ffmpeg?.inputs?.some((input) =>
input.roles?.includes("audio"),
);
}
return false;
},
},
],
restartRequired: [], restartRequired: [],
fieldOrder: [ fieldOrder: [
"enabled", "enabled",

View File

@ -3,19 +3,6 @@ import type { SectionConfigOverrides } from "./types";
const audioTranscription: SectionConfigOverrides = { const audioTranscription: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/audio_detectors#audio-transcription", sectionDocs: "/configuration/audio_detectors#audio-transcription",
messages: [
{
key: "audio-detection-disabled",
messageKey: "configMessages.audioTranscription.audioDetectionDisabled",
severity: "warning",
condition: (ctx) => {
if (ctx.level === "camera" && ctx.fullCameraConfig) {
return ctx.fullCameraConfig.audio.enabled === false;
}
return false;
},
},
],
restartRequired: [], restartRequired: [],
fieldOrder: ["enabled", "language", "device", "model_size"], fieldOrder: ["enabled", "language", "device", "model_size"],
hiddenFields: ["enabled_in_config", "live_enabled"], hiddenFields: ["enabled_in_config", "live_enabled"],

View File

@ -3,20 +3,6 @@ import type { SectionConfigOverrides } from "./types";
const birdseye: SectionConfigOverrides = { const birdseye: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/birdseye", sectionDocs: "/configuration/birdseye",
messages: [
{
key: "objects-mode-detect-disabled",
messageKey: "configMessages.birdseye.objectsModeDetectDisabled",
severity: "info",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
return (
ctx.formData?.mode === "objects" &&
ctx.fullCameraConfig.detect?.enabled === false
);
},
},
],
restartRequired: [], restartRequired: [],
fieldOrder: ["enabled", "mode", "order"], fieldOrder: ["enabled", "mode", "order"],
hiddenFields: [], hiddenFields: [],

View File

@ -3,21 +3,6 @@ import type { SectionConfigOverrides } from "./types";
const detect: SectionConfigOverrides = { const detect: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/camera_specific", sectionDocs: "/configuration/camera_specific",
fieldMessages: [
{
key: "fps-greater-than-five",
field: "fps",
messageKey: "configMessages.detect.fpsGreaterThanFive",
severity: "info",
position: "after",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
const detectFps = ctx.formData?.fps as number | undefined;
const streamFps = ctx.fullCameraConfig.detect?.fps;
return detectFps != null && streamFps != null && detectFps > 5;
},
},
],
fieldOrder: [ fieldOrder: [
"enabled", "enabled",
"width", "width",

View File

@ -3,26 +3,6 @@ import type { SectionConfigOverrides } from "./types";
const faceRecognition: SectionConfigOverrides = { const faceRecognition: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/face_recognition", sectionDocs: "/configuration/face_recognition",
messages: [
{
key: "global-disabled",
messageKey: "configMessages.faceRecognition.globalDisabled",
severity: "warning",
condition: (ctx) => {
if (ctx.level !== "camera") return false;
return ctx.fullConfig.face_recognition?.enabled === false;
},
},
{
key: "person-not-tracked",
messageKey: "configMessages.faceRecognition.personNotTracked",
severity: "info",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
return !ctx.fullCameraConfig.objects?.track?.includes("person");
},
},
],
restartRequired: [], restartRequired: [],
fieldOrder: ["enabled", "min_area"], fieldOrder: ["enabled", "min_area"],
hiddenFields: [], hiddenFields: [],

View File

@ -3,28 +3,6 @@ import type { SectionConfigOverrides } from "./types";
const lpr: SectionConfigOverrides = { const lpr: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/license_plate_recognition", sectionDocs: "/configuration/license_plate_recognition",
messages: [
{
key: "global-disabled",
messageKey: "configMessages.lpr.globalDisabled",
severity: "warning",
condition: (ctx) => {
if (ctx.level !== "camera") return false;
return ctx.fullConfig.lpr?.enabled === false;
},
},
{
key: "vehicle-not-tracked",
messageKey: "configMessages.lpr.vehicleNotTracked",
severity: "info",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
if (ctx.fullCameraConfig.type === "lpr") return false;
const tracked = ctx.fullCameraConfig.objects?.track ?? [];
return !tracked.some((o) => ["car", "motorcycle"].includes(o));
},
},
],
fieldDocs: { fieldDocs: {
enhancement: "/configuration/license_plate_recognition#enhancement", enhancement: "/configuration/license_plate_recognition#enhancement",
}, },

View File

@ -3,19 +3,6 @@ import type { SectionConfigOverrides } from "./types";
const record: SectionConfigOverrides = { const record: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/record", sectionDocs: "/configuration/record",
messages: [
{
key: "no-record-role",
messageKey: "configMessages.record.noRecordRole",
severity: "warning",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
return !ctx.fullCameraConfig.ffmpeg?.inputs?.some((i) =>
i.roles?.includes("record"),
);
},
},
],
restartRequired: [], restartRequired: [],
fieldOrder: [ fieldOrder: [
"enabled", "enabled",

View File

@ -3,45 +3,6 @@ import type { SectionConfigOverrides } from "./types";
const review: SectionConfigOverrides = { const review: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/review", sectionDocs: "/configuration/review",
messages: [
{
key: "record-disabled",
messageKey: "configMessages.review.recordDisabled",
severity: "warning",
condition: (ctx) => {
if (ctx.level === "camera" && ctx.fullCameraConfig) {
return ctx.fullCameraConfig.record.enabled === false;
}
return ctx.fullConfig.record?.enabled === false;
},
},
{
key: "detect-disabled",
messageKey: "configMessages.review.detectDisabled",
severity: "info",
condition: (ctx) => {
if (ctx.level === "camera" && ctx.fullCameraConfig) {
return ctx.fullCameraConfig.detect?.enabled === false;
}
return false;
},
},
],
fieldMessages: [
{
key: "detections-all-non-alert",
field: "detections.labels",
messageKey: "configMessages.review.allNonAlertDetections",
severity: "info",
position: "after",
condition: (ctx) => {
const labels = (
ctx.formData?.detections as Record<string, unknown> | undefined
)?.labels;
return !Array.isArray(labels) || labels.length === 0;
},
},
],
fieldDocs: { fieldDocs: {
"alerts.labels": "/configuration/review/#alerts-and-detections", "alerts.labels": "/configuration/review/#alerts-and-detections",
"detections.labels": "/configuration/review/#alerts-and-detections", "detections.labels": "/configuration/review/#alerts-and-detections",
@ -74,6 +35,8 @@ const review: SectionConfigOverrides = {
"ui:widget": "reviewLabels", "ui:widget": "reviewLabels",
"ui:options": { "ui:options": {
suppressMultiSchema: true, suppressMultiSchema: true,
emptySelectionHintKey:
"configForm.reviewLabels.allNonAlertDetections",
}, },
}, },
required_zones: { required_zones: {

View File

@ -3,17 +3,6 @@ import type { SectionConfigOverrides } from "./types";
const snapshots: SectionConfigOverrides = { const snapshots: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/snapshots", sectionDocs: "/configuration/snapshots",
messages: [
{
key: "detect-disabled",
messageKey: "configMessages.snapshots.detectDisabled",
severity: "info",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
return ctx.fullCameraConfig.detect?.enabled === false;
},
},
],
restartRequired: [], restartRequired: [],
fieldOrder: [ fieldOrder: [
"enabled", "enabled",

View File

@ -1,39 +1,5 @@
import type { FrigateConfig, CameraConfig } from "@/types/frigateConfig";
import type { ConfigSectionData } from "@/types/configForm";
import type { SectionConfig } from "../sections/BaseSection"; import type { SectionConfig } from "../sections/BaseSection";
/** Context provided to message condition functions */
export type MessageConditionContext = {
fullConfig: FrigateConfig;
fullCameraConfig?: CameraConfig;
level: "global" | "camera";
cameraName?: string;
formData: ConfigSectionData;
};
/** Severity levels for conditional messages */
export type MessageSeverity = "info" | "warning" | "error";
/** A conditional message definition */
export type ConditionalMessage = {
/** Unique key for React list rendering and deduplication */
key: string;
/** Translation key resolved via t() in the views/settings namespace */
messageKey: string;
/** Severity level controlling visual styling */
severity: MessageSeverity;
/** Function returning true when the message should be shown */
condition: (ctx: MessageConditionContext) => boolean;
};
/** Field-level conditional message, adds field targeting */
export type FieldConditionalMessage = ConditionalMessage & {
/** Dot-separated field path (e.g., "enabled", "alerts.labels") */
field: string;
/** Whether to render before or after the field (default: "before") */
position?: "before" | "after";
};
export type SectionConfigOverrides = { export type SectionConfigOverrides = {
base?: SectionConfig; base?: SectionConfig;
global?: Partial<SectionConfig>; global?: Partial<SectionConfig>;

View File

@ -43,7 +43,6 @@ import {
SelectItem, SelectItem,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { use24HourTime } from "@/hooks/use-date-utils";
import FilterSwitch from "@/components/filter/FilterSwitch"; import FilterSwitch from "@/components/filter/FilterSwitch";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
@ -753,7 +752,6 @@ export function CameraNotificationSwitch({
}; };
const locale = useDateLocale(); const locale = useDateLocale();
const is24Hour = use24HourTime(config);
const formatSuspendedUntil = (timestamp: string) => { const formatSuspendedUntil = (timestamp: string) => {
if (timestamp === "0") return t("time.untilForRestart", { ns: "common" }); if (timestamp === "0") return t("time.untilForRestart", { ns: "common" });
@ -762,13 +760,14 @@ export function CameraNotificationSwitch({
time_style: "medium", time_style: "medium",
date_style: "medium", date_style: "medium",
timezone: config?.ui.timezone, timezone: config?.ui.timezone,
date_format: is24Hour date_format:
? t("time.formattedTimestampMonthDayHourMinute.24hour", { config?.ui.time_format == "24hour"
ns: "common", ? t("time.formattedTimestampMonthDayHourMinute.24hour", {
}) ns: "common",
: t("time.formattedTimestampMonthDayHourMinute.12hour", { })
ns: "common", : t("time.formattedTimestampMonthDayHourMinute.12hour", {
}), ns: "common",
}),
locale: locale, locale: locale,
}); });
return t("time.untilForTime", { ns: "common", time }); return t("time.untilForTime", { ns: "common", time });

View File

@ -71,13 +71,6 @@ import {
} from "@/utils/configUtil"; } from "@/utils/configUtil";
import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import { useRestart } from "@/api/ws"; import { useRestart } from "@/api/ws";
import type {
ConditionalMessage,
FieldConditionalMessage,
MessageConditionContext,
} from "../section-configs/types";
import { useConfigMessages } from "@/hooks/use-config-messages";
import { ConfigMessageBanner } from "../ConfigMessageBanner";
export interface SectionConfig { export interface SectionConfig {
/** Field ordering within the section */ /** Field ordering within the section */
@ -107,10 +100,6 @@ export interface SectionConfig {
formData: unknown, formData: unknown,
errors: FormValidation, errors: FormValidation,
) => FormValidation; ) => FormValidation;
/** Conditional messages displayed as banners above the section form */
messages?: ConditionalMessage[];
/** Conditional messages displayed inline with specific fields */
fieldMessages?: FieldConditionalMessage[];
} }
export interface BaseSectionProps { export interface BaseSectionProps {
@ -547,65 +536,6 @@ export function ConfigSection({
const currentFormData = pendingData || formData; const currentFormData = pendingData || formData;
const effectiveBaselineFormData = baselineSnapshot; const effectiveBaselineFormData = baselineSnapshot;
// Build context for conditional messages
const messageContext = useMemo<MessageConditionContext | undefined>(() => {
if (!config || !currentFormData) return undefined;
return {
fullConfig: config,
fullCameraConfig:
effectiveLevel === "camera" && cameraName
? config.cameras?.[cameraName]
: undefined,
level: effectiveLevel,
cameraName,
formData: currentFormData as ConfigSectionData,
};
}, [config, currentFormData, effectiveLevel, cameraName]);
const { activeMessages, activeFieldMessages } = useConfigMessages(
sectionConfig.messages,
sectionConfig.fieldMessages,
messageContext,
);
// Merge field-level conditional messages into uiSchema
const effectiveUiSchema = useMemo(() => {
if (activeFieldMessages.length === 0) return sectionConfig.uiSchema;
const merged = { ...(sectionConfig.uiSchema ?? {}) };
for (const msg of activeFieldMessages) {
const segments = msg.field.split(".");
// Navigate to the nested uiSchema node, shallow-cloning along the way
let node = merged;
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
node[seg] = { ...(node[seg] as Record<string, unknown>) };
node = node[seg] as Record<string, unknown>;
}
const leafKey = segments[segments.length - 1];
const existing = node[leafKey] as Record<string, unknown> | undefined;
const existingMessages = ((existing?.["ui:messages"] as unknown[]) ??
[]) as Array<{
key: string;
messageKey: string;
severity: string;
position?: string;
}>;
node[leafKey] = {
...existing,
"ui:messages": [
...existingMessages,
{
key: msg.key,
messageKey: msg.messageKey,
severity: msg.severity,
position: msg.position ?? "before",
},
],
};
}
return merged;
}, [sectionConfig.uiSchema, activeFieldMessages]);
const currentOverrides = useMemo(() => { const currentOverrides = useMemo(() => {
if (!currentFormData || typeof currentFormData !== "object") { if (!currentFormData || typeof currentFormData !== "object") {
return undefined; return undefined;
@ -944,7 +874,6 @@ export function ConfigSection({
const sectionContent = ( const sectionContent = (
<div className="space-y-6"> <div className="space-y-6">
<ConfigMessageBanner messages={activeMessages} />
<ConfigForm <ConfigForm
key={formKey} key={formKey}
schema={modifiedSchema} schema={modifiedSchema}
@ -956,7 +885,7 @@ export function ConfigSection({
hiddenFields={effectiveHiddenFields} hiddenFields={effectiveHiddenFields}
advancedFields={sectionConfig.advancedFields} advancedFields={sectionConfig.advancedFields}
liveValidate={sectionConfig.liveValidate} liveValidate={sectionConfig.liveValidate}
uiSchema={effectiveUiSchema} uiSchema={sectionConfig.uiSchema}
disabled={disabled || isSaving} disabled={disabled || isSaving}
readonly={readonly} readonly={readonly}
showSubmit={false} showSubmit={false}

View File

@ -28,7 +28,6 @@ import {
} from "../utils"; } from "../utils";
import { normalizeOverridePath } from "../utils/overrides"; import { normalizeOverridePath } from "../utils/overrides";
import get from "lodash/get"; import get from "lodash/get";
import { ConfigFieldMessage } from "../../ConfigFieldMessage";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import { SPLIT_ROW_CLASS_NAME } from "@/components/card/SettingsGroupCard"; import { SPLIT_ROW_CLASS_NAME } from "@/components/card/SettingsGroupCard";
@ -383,46 +382,6 @@ export function FieldTemplate(props: FieldTemplateProps) {
const beforeContent = renderCustom(beforeSpec); const beforeContent = renderCustom(beforeSpec);
const afterContent = renderCustom(afterSpec); const afterContent = renderCustom(afterSpec);
// Render conditional field messages from ui:messages
const fieldMessageSpecs = uiSchema?.["ui:messages"] as
| Array<{
key: string;
messageKey: string;
severity: string;
position?: string;
}>
| undefined;
const beforeMessages = fieldMessageSpecs?.filter(
(m) => (m.position ?? "before") === "before",
);
const afterMessages = fieldMessageSpecs?.filter(
(m) => m.position === "after",
);
const beforeMessagesContent =
beforeMessages && beforeMessages.length > 0 ? (
<div className="space-y-2">
{beforeMessages.map((m) => (
<ConfigFieldMessage
key={m.key}
messageKey={m.messageKey}
severity={m.severity}
/>
))}
</div>
) : null;
const afterMessagesContent =
afterMessages && afterMessages.length > 0 ? (
<div className="space-y-2">
{afterMessages.map((m) => (
<ConfigFieldMessage
key={m.key}
messageKey={m.messageKey}
severity={m.severity}
/>
))}
</div>
) : null;
const WrapIfAdditionalTemplate = getTemplate( const WrapIfAdditionalTemplate = getTemplate(
"WrapIfAdditionalTemplate", "WrapIfAdditionalTemplate",
registry, registry,
@ -641,7 +600,6 @@ export function FieldTemplate(props: FieldTemplateProps) {
> >
<div className="flex flex-col space-y-6"> <div className="flex flex-col space-y-6">
{beforeContent} {beforeContent}
{beforeMessagesContent}
<div className={cn("space-y-1")} data-field-id={translationPath}> <div className={cn("space-y-1")} data-field-id={translationPath}>
{renderStandardLabel()} {renderStandardLabel()}
{renderFieldLayout()} {renderFieldLayout()}
@ -649,7 +607,6 @@ export function FieldTemplate(props: FieldTemplateProps) {
{errors} {errors}
{help} {help}
</div> </div>
{afterMessagesContent}
{afterContent} {afterContent}
</div> </div>
</WrapIfAdditionalTemplate> </WrapIfAdditionalTemplate>

View File

@ -45,6 +45,8 @@ export type SwitchesWidgetOptions = {
enableSearch?: boolean; enableSearch?: boolean;
/** Allow users to add custom entries not in the predefined list */ /** Allow users to add custom entries not in the predefined list */
allowCustomEntries?: boolean; allowCustomEntries?: boolean;
/** i18n key for a hint shown when no entities are selected */
emptySelectionHintKey?: string;
}; };
function normalizeValue(value: unknown): string[] { function normalizeValue(value: unknown): string[] {
@ -129,6 +131,11 @@ export function SwitchesWidget(props: WidgetProps) {
[props.options], [props.options],
); );
const emptySelectionHintKey = useMemo(
() => props.options?.emptySelectionHintKey as string | undefined,
[props.options],
);
const selectedEntities = useMemo(() => normalizeValue(value), [value]); const selectedEntities = useMemo(() => normalizeValue(value), [value]);
const [isOpen, setIsOpen] = useState(selectedEntities.length > 0); const [isOpen, setIsOpen] = useState(selectedEntities.length > 0);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@ -208,6 +215,12 @@ export function SwitchesWidget(props: WidgetProps) {
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
{emptySelectionHintKey && selectedEntities.length === 0 && t && (
<div className="mt-0 pb-2 text-sm text-success">
{t(emptySelectionHintKey, { ns: namespace })}
</div>
)}
<CollapsibleContent className="rounded-lg border border-input bg-secondary pb-1 pr-0 pt-2 md:max-w-md"> <CollapsibleContent className="rounded-lg border border-input bg-secondary pb-1 pr-0 pt-2 md:max-w-md">
{allEntities.length === 0 && !allowCustomEntries ? ( {allEntities.length === 0 && !allowCustomEntries ? (
<div className="text-sm text-muted-foreground">{emptyMessage}</div> <div className="text-sm text-muted-foreground">{emptyMessage}</div>

View File

@ -2,13 +2,12 @@ import { useTheme } from "@/context/theme-provider";
import { useDateLocale } from "@/hooks/use-date-locale"; import { useDateLocale } from "@/hooks/use-date-locale";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo } from "react";
import Chart from "react-apexcharts"; import Chart from "react-apexcharts";
import { isMobileOnly } from "react-device-detect"; import { isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MdCircle } from "react-icons/md"; import { MdCircle } from "react-icons/md";
import useSWR from "swr"; import useSWR from "swr";
import { useTimeFormat } from "@/hooks/use-date-utils";
const GRAPH_COLORS = ["#5C7CFA", "#ED5CFA", "#FAD75C"]; const GRAPH_COLORS = ["#5C7CFA", "#ED5CFA", "#FAD75C"];
@ -18,7 +17,6 @@ type CameraLineGraphProps = {
dataLabels: string[]; dataLabels: string[];
updateTimes: number[]; updateTimes: number[];
data: ApexAxisChartSeries; data: ApexAxisChartSeries;
isActive?: boolean;
}; };
export function CameraLineGraph({ export function CameraLineGraph({
graphId, graphId,
@ -26,7 +24,6 @@ export function CameraLineGraph({
dataLabels, dataLabels,
updateTimes, updateTimes,
data, data,
isActive = true,
}: CameraLineGraphProps) { }: CameraLineGraphProps) {
const { t } = useTranslation(["views/system", "common"]); const { t } = useTranslation(["views/system", "common"]);
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
@ -49,7 +46,7 @@ export function CameraLineGraph({
const locale = useDateLocale(); const locale = useDateLocale();
const timeFormat = useTimeFormat(config); const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
const format = useMemo(() => { const format = useMemo(() => {
return t(`time.formattedTimestampHourMinute.${timeFormat}`, { return t(`time.formattedTimestampHourMinute.${timeFormat}`, {
ns: "common", ns: "common",
@ -137,16 +134,6 @@ export function CameraLineGraph({
ApexCharts.exec(graphId, "updateOptions", options, true, true); ApexCharts.exec(graphId, "updateOptions", options, true, true);
}, [graphId, options]); }, [graphId, options]);
const hasBeenActive = useRef(isActive);
useEffect(() => {
if (isActive && hasBeenActive.current === false) {
ApexCharts.exec(graphId, "updateSeries", data, true);
}
hasBeenActive.current = isActive;
// only replay animation on visibility change, not data updates
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive, graphId]);
return ( return (
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
{lastValues && ( {lastValues && (
@ -179,7 +166,6 @@ type EventsPerSecondLineGraphProps = {
name: string; name: string;
updateTimes: number[]; updateTimes: number[];
data: ApexAxisChartSeries; data: ApexAxisChartSeries;
isActive?: boolean;
}; };
export function EventsPerSecondsLineGraph({ export function EventsPerSecondsLineGraph({
graphId, graphId,
@ -187,7 +173,6 @@ export function EventsPerSecondsLineGraph({
name, name,
updateTimes, updateTimes,
data, data,
isActive = true,
}: EventsPerSecondLineGraphProps) { }: EventsPerSecondLineGraphProps) {
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false, revalidateOnFocus: false,
@ -204,7 +189,7 @@ export function EventsPerSecondsLineGraph({
const locale = useDateLocale(); const locale = useDateLocale();
const { t } = useTranslation(["common"]); const { t } = useTranslation(["common"]);
const timeFormat = useTimeFormat(config); const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
const format = useMemo(() => { const format = useMemo(() => {
return t(`time.formattedTimestampHourMinute.${timeFormat}`, { return t(`time.formattedTimestampHourMinute.${timeFormat}`, {
ns: "common", ns: "common",
@ -292,16 +277,6 @@ export function EventsPerSecondsLineGraph({
ApexCharts.exec(graphId, "updateOptions", options, true, true); ApexCharts.exec(graphId, "updateOptions", options, true, true);
}, [graphId, options]); }, [graphId, options]);
const hasBeenActive = useRef(isActive);
useEffect(() => {
if (isActive && hasBeenActive.current === false) {
ApexCharts.exec(graphId, "updateSeries", data, true);
}
hasBeenActive.current = isActive;
// only replay animation on visibility change, not data updates
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive, graphId]);
return ( return (
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View File

@ -3,12 +3,11 @@ import { useDateLocale } from "@/hooks/use-date-locale";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { Threshold } from "@/types/graph"; import { Threshold } from "@/types/graph";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo } from "react";
import Chart from "react-apexcharts"; import Chart from "react-apexcharts";
import { isMobileOnly } from "react-device-detect"; import { isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR from "swr"; import useSWR from "swr";
import { useTimeFormat } from "@/hooks/use-date-utils";
type ThresholdBarGraphProps = { type ThresholdBarGraphProps = {
graphId: string; graphId: string;
@ -17,7 +16,6 @@ type ThresholdBarGraphProps = {
threshold: Threshold; threshold: Threshold;
updateTimes: number[]; updateTimes: number[];
data: ApexAxisChartSeries; data: ApexAxisChartSeries;
isActive?: boolean;
}; };
export function ThresholdBarGraph({ export function ThresholdBarGraph({
graphId, graphId,
@ -26,7 +24,6 @@ export function ThresholdBarGraph({
threshold, threshold,
updateTimes, updateTimes,
data, data,
isActive = true,
}: ThresholdBarGraphProps) { }: ThresholdBarGraphProps) {
const displayName = name || data[0]?.name || ""; const displayName = name || data[0]?.name || "";
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
@ -54,7 +51,7 @@ export function ThresholdBarGraph({
const locale = useDateLocale(); const locale = useDateLocale();
const { t } = useTranslation(["common"]); const { t } = useTranslation(["common"]);
const timeFormat = useTimeFormat(config); const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
const format = useMemo(() => { const format = useMemo(() => {
return t(`time.formattedTimestampHourMinute.${timeFormat}`, { return t(`time.formattedTimestampHourMinute.${timeFormat}`, {
ns: "common", ns: "common",
@ -176,29 +173,17 @@ export function ThresholdBarGraph({
return data; return data;
} }
const dataPointCount = data[0].data.length; const copiedData = [...data];
const fakeData = []; const fakeData = [];
for (let i = dataPointCount; i < 30; i++) { for (let i = data.length; i < 30; i++) {
fakeData.push({ x: i - 30, y: 0 }); fakeData.push({ x: i - 30, y: 0 });
} }
const paddedFirst = { // @ts-expect-error data types are not obvious
...data[0], copiedData[0].data = [...fakeData, ...data[0].data];
data: [...fakeData, ...data[0].data], return copiedData;
};
return [paddedFirst, ...data.slice(1)] as ApexAxisChartSeries;
}, [data]); }, [data]);
const hasBeenActive = useRef(isActive);
useEffect(() => {
if (isActive && hasBeenActive.current === false) {
ApexCharts.exec(graphId, "updateSeries", chartData, true);
}
hasBeenActive.current = isActive;
// only replay animation on visibility change, not data updates
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive, graphId]);
return ( return (
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View File

@ -50,7 +50,6 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { use24HourTime } from "@/hooks/use-date-utils";
import { MdImageSearch } from "react-icons/md"; import { MdImageSearch } from "react-icons/md";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
@ -81,8 +80,6 @@ export default function InputWithTags({
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
const is24Hour = use24HourTime(config);
const resolvedTimeFormat = is24Hour ? "24hour" : ("12hour" as const);
const allAudioListenLabels = useMemo<Set<string>>(() => { const allAudioListenLabels = useMemo<Set<string>>(() => {
if (!config) { if (!config) {
@ -434,8 +431,12 @@ export default function InputWithTags({
const [startTime, endTime] = (filterValues as string) const [startTime, endTime] = (filterValues as string)
.replace("-", ",") .replace("-", ",")
.split(","); .split(",");
return `${is24Hour ? startTime : convertTo12Hour(startTime)} - ${ return `${
is24Hour ? endTime : convertTo12Hour(endTime) config?.ui.time_format === "24hour"
? startTime
: convertTo12Hour(startTime)
} - ${
config?.ui.time_format === "24hour" ? endTime : convertTo12Hour(endTime)
}`; }`;
} else if (filterType === "min_score" || filterType === "max_score") { } else if (filterType === "min_score" || filterType === "max_score") {
return Math.round(Number(filterValues) * 100).toString() + "%"; return Math.round(Number(filterValues) * 100).toString() + "%";
@ -477,7 +478,7 @@ export default function InputWithTags({
(filterType === "time_range" && (filterType === "time_range" &&
isValidTimeRange( isValidTimeRange(
trimmedValue.replace("-", ","), trimmedValue.replace("-", ","),
resolvedTimeFormat, config?.ui.time_format,
)) || )) ||
((filterType === "min_score" || filterType === "max_score") && ((filterType === "min_score" || filterType === "max_score") &&
!isNaN(Number(trimmedValue)) && !isNaN(Number(trimmedValue)) &&
@ -494,7 +495,7 @@ export default function InputWithTags({
? trimmedValue ? trimmedValue
.replace("-", ",") .replace("-", ",")
.split(",") .split(",")
.map((time) => to24Hour(time.trim(), resolvedTimeFormat)) .map((time) => to24Hour(time.trim(), config?.ui.time_format))
.join(",") .join(",")
: trimmedValue, : trimmedValue,
); );
@ -510,7 +511,7 @@ export default function InputWithTags({
setCurrentFilterType(null); setCurrentFilterType(null);
} }
}, },
[allSuggestions, createFilter, resolvedTimeFormat], [allSuggestions, createFilter, config],
); );
const handleInputChange = useCallback( const handleInputChange = useCallback(
@ -597,7 +598,7 @@ export default function InputWithTags({
suggestion = suggestion suggestion = suggestion
.replace("-", ",") .replace("-", ",")
.split(",") .split(",")
.map((time) => to24Hour(time.trim(), resolvedTimeFormat)) .map((time) => to24Hour(time.trim(), config?.ui.time_format))
.join(","); .join(",");
} }
createFilter(currentFilterType, suggestion); createFilter(currentFilterType, suggestion);
@ -626,7 +627,7 @@ export default function InputWithTags({
inputRef.current?.focus(); inputRef.current?.focus();
}, },
[createFilter, currentFilterType, allSuggestions, resolvedTimeFormat], [createFilter, currentFilterType, allSuggestions, config],
); );
const handleSearch = useCallback( const handleSearch = useCallback(
@ -778,7 +779,10 @@ export default function InputWithTags({
</li> </li>
<li> <li>
{t("filter.tips.desc.step5", { {t("filter.tips.desc.step5", {
exampleTime: is24Hour ? "15:00-16:00" : "3:00PM-4:00PM", exampleTime:
config?.ui.time_format == "24hour"
? "15:00-16:00"
: "3:00PM-4:00PM",
})} })}
</li> </li>
<li>{t("filter.tips.desc.step6")}</li> <li>{t("filter.tips.desc.step6")}</li>

View File

@ -46,7 +46,6 @@ import {
} from "@/api/ws"; } from "@/api/ws";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale"; import { useDateLocale } from "@/hooks/use-date-locale";
import { use24HourTime } from "@/hooks/use-date-utils";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { CameraNameLabel } from "../camera/FriendlyNameLabel"; import { CameraNameLabel } from "../camera/FriendlyNameLabel";
import { LiveStreamMetadata } from "@/types/live"; import { LiveStreamMetadata } from "@/types/live";
@ -248,8 +247,6 @@ export default function LiveContextMenu({
const locale = useDateLocale(); const locale = useDateLocale();
const is24Hour = use24HourTime(config);
const formatSuspendedUntil = (timestamp: string) => { const formatSuspendedUntil = (timestamp: string) => {
// Some languages require a change in word order // Some languages require a change in word order
if (timestamp === "0") return t("time.untilForRestart", { ns: "common" }); if (timestamp === "0") return t("time.untilForRestart", { ns: "common" });
@ -258,13 +255,14 @@ export default function LiveContextMenu({
time_style: "medium", time_style: "medium",
date_style: "medium", date_style: "medium",
timezone: config?.ui.timezone, timezone: config?.ui.timezone,
date_format: is24Hour date_format:
? t("time.formattedTimestampMonthDayHourMinute.24hour", { config?.ui.time_format == "24hour"
ns: "common", ? t("time.formattedTimestampMonthDayHourMinute.24hour", {
}) ns: "common",
: t("time.formattedTimestampMonthDayHourMinute.12hour", { })
ns: "common", : t("time.formattedTimestampMonthDayHourMinute.12hour", {
}), ns: "common",
}),
locale: locale, locale: locale,
}); });
return t("time.untilForTime", { ns: "common", time }); return t("time.untilForTime", { ns: "common", time });

View File

@ -3,7 +3,7 @@ import { Button } from "../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { SelectSeparator } from "../ui/select"; import { SelectSeparator } from "../ui/select";
import { TimeRange } from "@/types/timeline"; import { TimeRange } from "@/types/timeline";
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils"; import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { getUTCOffset } from "@/utils/dateUtil"; import { getUTCOffset } from "@/utils/dateUtil";
import { TimezoneAwareCalendar } from "./ReviewActivityCalendar"; import { TimezoneAwareCalendar } from "./ReviewActivityCalendar";
import { FaArrowRight, FaCalendarAlt } from "react-icons/fa"; import { FaArrowRight, FaCalendarAlt } from "react-icons/fa";
@ -69,18 +69,16 @@ export function CustomTimeSelector({
return time; return time;
}, [range, latestTime, timezoneOffset, localTimeOffset]); }, [range, latestTime, timezoneOffset, localTimeOffset]);
const is24Hour = use24HourTime(config);
const formattedStart = useFormattedTimestamp( const formattedStart = useFormattedTimestamp(
startTime, startTime,
is24Hour config?.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour") ? t("time.formattedTimestamp.24hour")
: t("time.formattedTimestamp.12hour"), : t("time.formattedTimestamp.12hour"),
); );
const formattedEnd = useFormattedTimestamp( const formattedEnd = useFormattedTimestamp(
endTime, endTime,
is24Hour config?.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour") ? t("time.formattedTimestamp.24hour")
: t("time.formattedTimestamp.12hour"), : t("time.formattedTimestamp.12hour"),
); );

View File

@ -2,7 +2,7 @@ import { isDesktop, isIOS, isMobile, isSafari } from "react-device-detect";
import { SearchResult } from "@/types/search"; import { SearchResult } from "@/types/search";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils"; import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { Button } from "../../ui/button"; import { Button } from "../../ui/button";
@ -769,10 +769,9 @@ function ObjectDetailsTab({
setShowNavigationButtons, setShowNavigationButtons,
]); ]);
const is24Hour = use24HourTime(config);
const formattedDate = useFormattedTimestamp( const formattedDate = useFormattedTimestamp(
search?.start_time ?? 0, search?.start_time ?? 0,
is24Hour config?.ui.time_format == "24hour"
? t("time.formattedTimestampMonthDayYearHourMinute.24hour", { ? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
ns: "common", ns: "common",
}) })

View File

@ -7,7 +7,6 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import { TrackingDetailsSequence } from "@/types/timeline"; import { TrackingDetailsSequence } from "@/types/timeline";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { use24HourTime } from "@/hooks/use-date-utils";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import { LuCircle, LuFolderX } from "react-icons/lu"; import { LuCircle, LuFolderX } from "react-icons/lu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -429,18 +428,17 @@ export function TrackingDetails({
[annotationOffset, displaySource, timestampToVideoTime], [annotationOffset, displaySource, timestampToVideoTime],
); );
const is24Hour = use24HourTime(config);
const formattedStart = config const formattedStart = config
? formatUnixTimestampToDateTime(event.start_time ?? 0, { ? formatUnixTimestampToDateTime(event.start_time ?? 0, {
timezone: config.ui.timezone, timezone: config.ui.timezone,
date_format: is24Hour date_format:
? t("time.formattedTimestamp.24hour", { config.ui.time_format == "24hour"
ns: "common", ? t("time.formattedTimestamp.24hour", {
}) ns: "common",
: t("time.formattedTimestamp.12hour", { })
ns: "common", : t("time.formattedTimestamp.12hour", {
}), ns: "common",
}),
time_style: "medium", time_style: "medium",
date_style: "medium", date_style: "medium",
}) })
@ -450,13 +448,14 @@ export function TrackingDetails({
config && event.end_time != null config && event.end_time != null
? formatUnixTimestampToDateTime(event.end_time, { ? formatUnixTimestampToDateTime(event.end_time, {
timezone: config.ui.timezone, timezone: config.ui.timezone,
date_format: is24Hour date_format:
? t("time.formattedTimestamp.24hour", { config.ui.time_format == "24hour"
ns: "common", ? t("time.formattedTimestamp.24hour", {
}) ns: "common",
: t("time.formattedTimestamp.12hour", { })
ns: "common", : t("time.formattedTimestamp.12hour", {
}), ns: "common",
}),
time_style: "medium", time_style: "medium",
date_style: "medium", date_style: "medium",
}) })
@ -918,25 +917,24 @@ function LifecycleIconRow({
[effectiveTime, item.timestamp], [effectiveTime, item.timestamp],
); );
const is24Hour = use24HourTime(config);
const formattedEventTimestamp = useMemo( const formattedEventTimestamp = useMemo(
() => () =>
config config
? formatUnixTimestampToDateTime(item.timestamp ?? 0, { ? formatUnixTimestampToDateTime(item.timestamp ?? 0, {
timezone: config.ui.timezone, timezone: config.ui.timezone,
date_format: is24Hour date_format:
? t("time.formattedTimestampHourMinuteSecond.24hour", { config.ui.time_format == "24hour"
ns: "common", ? t("time.formattedTimestampHourMinuteSecond.24hour", {
}) ns: "common",
: t("time.formattedTimestampHourMinuteSecond.12hour", { })
ns: "common", : t("time.formattedTimestampHourMinuteSecond.12hour", {
}), ns: "common",
}),
time_style: "medium", time_style: "medium",
date_style: "medium", date_style: "medium",
}) })
: "", : "",
[config, is24Hour, item.timestamp, t], [config, item.timestamp, t],
); );
const ratio = useMemo( const ratio = useMemo(

View File

@ -12,7 +12,7 @@ import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { isIOS, isMobile, isSafari } from "react-device-detect"; import { isIOS, isMobile, isSafari } from "react-device-detect";
import Chip from "@/components/indicators/Chip"; import Chip from "@/components/indicators/Chip";
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils"; import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import useImageLoaded from "@/hooks/use-image-loaded"; import useImageLoaded from "@/hooks/use-image-loaded";
import { useSwipeable } from "react-swipeable"; import { useSwipeable } from "react-swipeable";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
@ -174,10 +174,9 @@ export default function PreviewThumbnailPlayer({
// date // date
const is24Hour = use24HourTime(config);
const formattedDate = useFormattedTimestamp( const formattedDate = useFormattedTimestamp(
review.start_time, review.start_time,
is24Hour config?.ui.time_format == "24hour"
? t("time.formattedTimestampMonthDayHourMinute.24hour", { ns: "common" }) ? t("time.formattedTimestampMonthDayHourMinute.24hour", { ns: "common" })
: t("time.formattedTimestampMonthDayHourMinute.12hour", { ns: "common" }), : t("time.formattedTimestampMonthDayHourMinute.12hour", { ns: "common" }),
config?.ui?.timezone, config?.ui?.timezone,

View File

@ -8,7 +8,6 @@ import {
formatUnixTimestampToDateTime, formatUnixTimestampToDateTime,
getDurationFromTimestamps, getDurationFromTimestamps,
} from "@/utils/dateUtil"; } from "@/utils/dateUtil";
import { use24HourTime } from "@/hooks/use-date-utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffsetSlider"; import AnnotationOffsetSlider from "@/components/overlay/detail/AnnotationOffsetSlider";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
@ -399,12 +398,12 @@ function ReviewGroup({
} }
}, [isActive, alwaysExpandActive]); }, [isActive, alwaysExpandActive]);
const is24Hour = use24HourTime(config);
const displayTime = formatUnixTimestampToDateTime(start, { const displayTime = formatUnixTimestampToDateTime(start, {
timezone: config.ui.timezone, timezone: config.ui.timezone,
date_format: is24Hour date_format:
? t("time.formattedTimestampHourMinuteSecond.24hour", { ns: "common" }) config.ui.time_format == "24hour"
: t("time.formattedTimestampHourMinuteSecond.12hour", { ns: "common" }), ? t("time.formattedTimestampHourMinuteSecond.24hour", { ns: "common" })
: t("time.formattedTimestampHourMinuteSecond.12hour", { ns: "common" }),
time_style: "medium", time_style: "medium",
date_style: "medium", date_style: "medium",
}); });
@ -788,17 +787,17 @@ function LifecycleItem({
); );
}, [config, item]); }, [config, item]);
const is24Hour = use24HourTime(config);
const formattedEventTimestamp = config const formattedEventTimestamp = config
? formatUnixTimestampToDateTime(item?.timestamp ?? 0, { ? formatUnixTimestampToDateTime(item?.timestamp ?? 0, {
timezone: config.ui.timezone, timezone: config.ui.timezone,
date_format: is24Hour date_format:
? t("time.formattedTimestampHourMinuteSecond.24hour", { config.ui.time_format == "24hour"
ns: "common", ? t("time.formattedTimestampHourMinuteSecond.24hour", {
}) ns: "common",
: t("time.formattedTimestampHourMinuteSecond.12hour", { })
ns: "common", : t("time.formattedTimestampHourMinuteSecond.12hour", {
}), ns: "common",
}),
time_style: "medium", time_style: "medium",
date_style: "medium", date_style: "medium",
}) })

View File

@ -1,4 +1,4 @@
import { useFormattedTimestamp, useTimeFormat } from "@/hooks/use-date-utils"; import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -36,7 +36,7 @@ export function MinimapBounds({
}: MinimapSegmentProps) { }: MinimapSegmentProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const { t } = useTranslation(["common"]); const { t } = useTranslation(["common"]);
const timeFormat = useTimeFormat(config); const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
const formatKey = dense const formatKey = dense
? `time.formattedTimestampHourMinute.${timeFormat}` ? `time.formattedTimestampHourMinute.${timeFormat}`
@ -104,7 +104,7 @@ export function Timestamp({
const { t } = useTranslation(["common"]); const { t } = useTranslation(["common"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const timeFormat = useTimeFormat(config); const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
const format = t(`time.formattedTimestampHourMinute.${timeFormat}`); const format = t(`time.formattedTimestampHourMinute.${timeFormat}`);
const formattedTimestamp = useFormattedTimestamp( const formattedTimestamp = useFormattedTimestamp(

View File

@ -11,9 +11,6 @@ const alertVariants = cva(
default: "bg-background text-foreground", default: "bg-background text-foreground",
destructive: destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
warning:
"border-amber-500/50 bg-amber-50 text-amber-800 dark:bg-amber-950/20 dark:text-amber-400 dark:border-amber-500/30 [&>svg]:text-amber-600 dark:[&>svg]:text-amber-400",
info: "border-blue-500/50 bg-blue-50 text-blue-800 dark:bg-blue-950/20 dark:text-blue-400 dark:border-blue-500/30 [&>svg]:text-blue-600 dark:[&>svg]:text-blue-400",
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@ -1,27 +0,0 @@
import { useMemo } from "react";
import type {
ConditionalMessage,
FieldConditionalMessage,
MessageConditionContext,
} from "@/components/config-form/section-configs/types";
export function useConfigMessages(
messages: ConditionalMessage[] | undefined,
fieldMessages: FieldConditionalMessage[] | undefined,
context: MessageConditionContext | undefined,
): {
activeMessages: ConditionalMessage[];
activeFieldMessages: FieldConditionalMessage[];
} {
const activeMessages = useMemo(() => {
if (!messages || !context) return [];
return messages.filter((msg) => msg.condition(context));
}, [messages, context]);
const activeFieldMessages = useMemo(() => {
if (!fieldMessages || !context) return [];
return fieldMessages.filter((msg) => msg.condition(context));
}, [fieldMessages, context]);
return { activeMessages, activeFieldMessages };
}

View File

@ -84,18 +84,6 @@ export function use24HourTime(config: FrigateConfig | undefined) {
}, [config, localeUses24HourTime]); }, [config, localeUses24HourTime]);
} }
/**
* Returns the resolved time format key ("24hour" | "12hour") based on config
* and browser locale. Use this instead of checking config.ui.time_format directly
* to correctly handle the "browser" setting.
*/
export function useTimeFormat(
config: FrigateConfig | undefined,
): "24hour" | "12hour" {
const is24Hour = use24HourTime(config);
return is24Hour ? "24hour" : "12hour";
}
export function useFormattedHour( export function useFormattedHour(
config: FrigateConfig | undefined, config: FrigateConfig | undefined,
time: string, // hour is assumed to be in 24 hour format per the Date object time: string, // hour is assumed to be in 24 hour format per the Date object

View File

@ -4,7 +4,6 @@ import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { useDateLocale } from "./use-date-locale"; import { useDateLocale } from "./use-date-locale";
import { useTimeFormat } from "./use-date-utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useUserInteraction from "./use-user-interaction"; import useUserInteraction from "./use-user-interaction";
@ -169,7 +168,7 @@ function useDraggableElement({
const { t } = useTranslation(["common"]); const { t } = useTranslation(["common"]);
const locale = useDateLocale(); const locale = useDateLocale();
const timeFormat = useTimeFormat(config); const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
const format = useMemo(() => { const format = useMemo(() => {
const formatKey = `time.${ const formatKey = `time.${
segmentDuration < 60 && !dense segmentDuration < 60 && !dense

View File

@ -1,6 +1,6 @@
import useSWR from "swr"; import useSWR from "swr";
import { FrigateStats } from "@/types/stats"; import { FrigateStats } from "@/types/stats";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import TimeAgo from "@/components/dynamic/TimeAgo"; import TimeAgo from "@/components/dynamic/TimeAgo";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
@ -49,17 +49,7 @@ function System() {
setPage, setPage,
100, 100,
); );
const [lastUpdated, setLastUpdated] = useState<number>( const [lastUpdated, setLastUpdated] = useState<number>(Date.now() / 1000);
Math.floor(Date.now() / 1000),
);
// Track which tabs have been visited so we can keep them mounted after first visit.
// Using a ref updated during render avoids extra render cycles from state/effects.
const visitedTabsRef = useRef(new Set<string>());
if (page) {
visitedTabsRef.current.add(page);
}
const visitedTabs = visitedTabsRef.current;
useEffect(() => { useEffect(() => {
if (pageToggle) { if (pageToggle) {
@ -126,37 +116,24 @@ function System() {
</div> </div>
)} )}
</div> </div>
{visitedTabs.has("general") && ( {page == "general" && (
<div className={page == "general" ? "contents" : "hidden"}> <GeneralMetrics
<GeneralMetrics lastUpdated={lastUpdated}
lastUpdated={lastUpdated} setLastUpdated={setLastUpdated}
setLastUpdated={setLastUpdated} />
isActive={page == "general"}
/>
</div>
)} )}
{metrics.includes("enrichments") && visitedTabs.has("enrichments") && ( {page == "enrichments" && (
<div className={page == "enrichments" ? "contents" : "hidden"}> <EnrichmentMetrics
<EnrichmentMetrics lastUpdated={lastUpdated}
lastUpdated={lastUpdated} setLastUpdated={setLastUpdated}
setLastUpdated={setLastUpdated} />
isActive={page == "enrichments"}
/>
</div>
)} )}
{visitedTabs.has("storage") && ( {page == "storage" && <StorageMetrics setLastUpdated={setLastUpdated} />}
<div className={page == "storage" ? "contents" : "hidden"}> {page == "cameras" && (
<StorageMetrics setLastUpdated={setLastUpdated} /> <CameraMetrics
</div> lastUpdated={lastUpdated}
)} setLastUpdated={setLastUpdated}
{visitedTabs.has("cameras") && ( />
<div className={page == "cameras" ? "contents" : "hidden"}>
<CameraMetrics
lastUpdated={lastUpdated}
setLastUpdated={setLastUpdated}
isActive={page == "cameras"}
/>
</div>
)} )}
</div> </div>
); );

View File

@ -28,9 +28,6 @@ export type CameraStats = {
expected_fps: number; expected_fps: number;
reconnects_last_hour: number; reconnects_last_hour: number;
stalls_last_hour: number; stalls_last_hour: number;
ffmpeg_cpu?: string;
capture_cpu?: string;
detect_cpu?: string;
}; };
export type CpuStats = { export type CpuStats = {
@ -45,8 +42,6 @@ export type DetectorStats = {
inference_speed: number; inference_speed: number;
pid: number; pid: number;
temperature?: number; temperature?: number;
cpu?: string;
mem?: string;
}; };
export type EmbeddingsStats = { export type EmbeddingsStats = {
@ -58,8 +53,6 @@ export type EmbeddingsStats = {
export type ExtraProcessStats = { export type ExtraProcessStats = {
pid: number; pid: number;
cpu?: string;
mem?: string;
}; };
export type GpuStats = { export type GpuStats = {
@ -67,10 +60,8 @@ export type GpuStats = {
mem: string; mem: string;
enc?: string; enc?: string;
dec?: string; dec?: string;
compute?: string;
pstate?: string; pstate?: string;
temp?: number; temp?: number;
clients?: { [pid: string]: string };
}; };
export type NpuStats = { export type NpuStats = {

View File

@ -19,7 +19,7 @@ import { useResizeObserver } from "@/hooks/resize-observer";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import TimeAgo from "@/components/dynamic/TimeAgo"; import TimeAgo from "@/components/dynamic/TimeAgo";
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils"; import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
const MOTION_HEATMAP_GRID_SIZE = 16; const MOTION_HEATMAP_GRID_SIZE = 16;
@ -165,10 +165,9 @@ function MotionPreviewClip({
const [fallbackFrameIndex, setFallbackFrameIndex] = useState(0); const [fallbackFrameIndex, setFallbackFrameIndex] = useState(0);
const [fallbackFramesReady, setFallbackFramesReady] = useState(false); const [fallbackFramesReady, setFallbackFramesReady] = useState(false);
const is24Hour = use24HourTime(config);
const formattedDate = useFormattedTimestamp( const formattedDate = useFormattedTimestamp(
range.start_time, range.start_time,
is24Hour config?.ui.time_format == "24hour"
? t("time.formattedTimestampMonthDayHourMinute.24hour", { ? t("time.formattedTimestampMonthDayHourMinute.24hour", {
ns: "common", ns: "common",
}) })

View File

@ -43,9 +43,8 @@ import { TimezoneAwareCalendar } from "@/components/overlay/ReviewActivityCalend
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils"; import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { getUTCOffset } from "@/utils/dateUtil"; import { getUTCOffset } from "@/utils/dateUtil";
import useSWR from "swr";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import MotionSearchROICanvas from "./MotionSearchROICanvas"; import MotionSearchROICanvas from "./MotionSearchROICanvas";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
@ -453,6 +452,7 @@ export default function MotionSearchDialog({
range={searchRange} range={searchRange}
setRange={setSearchRange} setRange={setSearchRange}
defaultRange={defaultRange} defaultRange={defaultRange}
timeFormat={config.ui?.time_format}
timezone={timezone} timezone={timezone}
/> />
@ -476,6 +476,7 @@ type SearchRangeSelectorProps = {
range?: TimeRange; range?: TimeRange;
setRange: React.Dispatch<React.SetStateAction<TimeRange | undefined>>; setRange: React.Dispatch<React.SetStateAction<TimeRange | undefined>>;
defaultRange: TimeRange; defaultRange: TimeRange;
timeFormat?: "browser" | "12hour" | "24hour";
timezone?: string; timezone?: string;
}; };
@ -483,6 +484,7 @@ function SearchRangeSelector({
range, range,
setRange, setRange,
defaultRange, defaultRange,
timeFormat,
timezone, timezone,
}: SearchRangeSelectorProps) { }: SearchRangeSelectorProps) {
const { t } = useTranslation(["views/motionSearch", "common"]); const { t } = useTranslation(["views/motionSearch", "common"]);
@ -525,18 +527,15 @@ function SearchRangeSelector({
return time; return time;
}, [range, defaultRange, timezoneOffset, localTimeOffset]); }, [range, defaultRange, timezoneOffset, localTimeOffset]);
const { data: config } = useSWR<FrigateConfig>("config");
const is24Hour = use24HourTime(config);
const formattedStart = useFormattedTimestamp( const formattedStart = useFormattedTimestamp(
startTime, startTime,
is24Hour timeFormat === "24hour"
? t("time.formattedTimestamp.24hour", { ns: "common" }) ? t("time.formattedTimestamp.24hour", { ns: "common" })
: t("time.formattedTimestamp.12hour", { ns: "common" }), : t("time.formattedTimestamp.12hour", { ns: "common" }),
); );
const formattedEnd = useFormattedTimestamp( const formattedEnd = useFormattedTimestamp(
endTime, endTime,
is24Hour timeFormat === "24hour"
? t("time.formattedTimestamp.24hour", { ns: "common" }) ? t("time.formattedTimestamp.24hour", { ns: "common" })
: t("time.formattedTimestamp.12hour", { ns: "common" }), : t("time.formattedTimestamp.12hour", { ns: "common" }),
); );

View File

@ -51,7 +51,7 @@ import {
RecordingSegment, RecordingSegment,
} from "@/types/record"; } from "@/types/record";
import { VideoResolutionType } from "@/types/live"; import { VideoResolutionType } from "@/types/live";
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils"; import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import MotionSearchROICanvas from "./MotionSearchROICanvas"; import MotionSearchROICanvas from "./MotionSearchROICanvas";
import MotionSearchDialog from "./MotionSearchDialog"; import MotionSearchDialog from "./MotionSearchDialog";
import { IoMdArrowRoundBack } from "react-icons/io"; import { IoMdArrowRoundBack } from "react-icons/io";
@ -94,13 +94,12 @@ export default function MotionSearchView({
]); ]);
const navigate = useNavigate(); const navigate = useNavigate();
const is24Hour = use24HourTime(config);
const resultTimestampFormat = useMemo( const resultTimestampFormat = useMemo(
() => () =>
is24Hour config.ui?.time_format === "24hour"
? t("time.formattedTimestamp.24hour", { ns: "common" }) ? t("time.formattedTimestamp.24hour", { ns: "common" })
: t("time.formattedTimestamp.12hour", { ns: "common" }), : t("time.formattedTimestamp.12hour", { ns: "common" }),
[is24Hour, t], [config.ui?.time_format, t],
); );
// Refs // Refs

View File

@ -31,7 +31,6 @@ import Chip from "@/components/indicators/Chip";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import SearchActionGroup from "@/components/filter/SearchActionGroup"; import SearchActionGroup from "@/components/filter/SearchActionGroup";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { use24HourTime } from "@/hooks/use-date-utils";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
@ -78,7 +77,6 @@ export default function SearchView({
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
const is24Hour = use24HourTime(config);
const navigate = useNavigate(); const navigate = useNavigate();
const { data: exploreEvents } = useSWR<SearchResult[]>( const { data: exploreEvents } = useSWR<SearchResult[]>(
@ -193,7 +191,10 @@ export default function SearchView({
sub_labels: allSubLabels, sub_labels: allSubLabels,
...(hasCustomClassificationModels && { attributes: allAttributes }), ...(hasCustomClassificationModels && { attributes: allAttributes }),
search_type: ["thumbnail", "description"] as SearchSource[], search_type: ["thumbnail", "description"] as SearchSource[],
time_range: is24Hour ? ["00:00-23:59"] : ["12:00AM-11:59PM"], time_range:
config?.ui.time_format == "24hour"
? ["00:00-23:59"]
: ["12:00AM-11:59PM"],
before: [formatDateToLocaleString()], before: [formatDateToLocaleString()],
after: [formatDateToLocaleString(-5)], after: [formatDateToLocaleString(-5)],
min_score: ["50"], min_score: ["50"],
@ -208,7 +209,6 @@ export default function SearchView({
}), }),
[ [
config, config,
is24Hour,
allLabels, allLabels,
allZones, allZones,
allSubLabels, allSubLabels,

View File

@ -39,7 +39,6 @@ import { Trigger, TriggerAction, TriggerType } from "@/types/trigger";
import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchEffect } from "@/hooks/use-overlay-state";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { use24HourTime } from "@/hooks/use-date-utils";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTriggers } from "@/api/ws"; import { useTriggers } from "@/api/ws";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
@ -90,7 +89,6 @@ export default function TriggerView({
const { t } = useTranslation("views/settings"); const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } = const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config"); useSWR<FrigateConfig>("config");
const is24Hour = use24HourTime(config);
const { data: trigger_status, mutate } = useSWR( const { data: trigger_status, mutate } = useSWR(
config?.cameras[selectedCamera]?.semantic_search?.triggers && config?.cameras[selectedCamera]?.semantic_search?.triggers &&
Object.keys(config.cameras[selectedCamera].semantic_search.triggers) Object.keys(config.cameras[selectedCamera].semantic_search.triggers)
@ -583,19 +581,20 @@ export default function TriggerView({
?.last_triggered, ?.last_triggered,
{ {
timezone: config.ui.timezone, timezone: config.ui.timezone,
date_format: is24Hour date_format:
? t( config.ui.time_format == "24hour"
"time.formattedTimestamp2.24hour", ? t(
{ "time.formattedTimestamp2.24hour",
ns: "common", {
}, ns: "common",
) },
: t( )
"time.formattedTimestamp2.12hour", : t(
{ "time.formattedTimestamp2.12hour",
ns: "common", {
}, ns: "common",
), },
),
time_style: "medium", time_style: "medium",
date_style: "medium", date_style: "medium",
}, },
@ -743,19 +742,20 @@ export default function TriggerView({
?.last_triggered, ?.last_triggered,
{ {
timezone: config.ui.timezone, timezone: config.ui.timezone,
date_format: is24Hour date_format:
? t( config.ui.time_format == "24hour"
"time.formattedTimestamp2.24hour", ? t(
{ "time.formattedTimestamp2.24hour",
ns: "common", {
}, ns: "common",
) },
: t( )
"time.formattedTimestamp2.12hour", : t(
{ "time.formattedTimestamp2.12hour",
ns: "common", {
}, ns: "common",
), },
),
time_style: "medium", time_style: "medium",
date_style: "medium", date_style: "medium",
}, },

View File

@ -5,14 +5,7 @@ import { ConnectionQualityIndicator } from "@/components/camera/ConnectionQualit
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { FrigateStats } from "@/types/stats"; import { FrigateStats } from "@/types/stats";
import { import { useCallback, useEffect, useMemo, useState } from "react";
Fragment,
startTransition,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { MdInfo } from "react-icons/md"; import { MdInfo } from "react-icons/md";
import { import {
Tooltip, Tooltip,
@ -27,12 +20,10 @@ import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
type CameraMetricsProps = { type CameraMetricsProps = {
lastUpdated: number; lastUpdated: number;
setLastUpdated: (last: number) => void; setLastUpdated: (last: number) => void;
isActive: boolean;
}; };
export default function CameraMetrics({ export default function CameraMetrics({
lastUpdated, lastUpdated,
setLastUpdated, setLastUpdated,
isActive,
}: CameraMetricsProps) { }: CameraMetricsProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const { t } = useTranslation(["views/system"]); const { t } = useTranslation(["views/system"]);
@ -48,11 +39,11 @@ export default function CameraMetrics({
// stats // stats
const { data: initialStats, mutate: refreshStats } = useSWR<FrigateStats[]>( const { data: initialStats } = useSWR<FrigateStats[]>(
[ [
"stats/history", "stats/history",
{ {
keys: "cameras.camera_fps,cameras.detection_fps,cameras.skipped_fps,cameras.ffmpeg_cpu,cameras.capture_cpu,cameras.detect_cpu,cameras.connection_quality,cameras.expected_fps,cameras.reconnects_last_hour,cameras.stalls_last_hour,camera_fps,detection_fps,skipped_fps,service.last_updated", keys: "cpu_usages,cameras,camera_fps,detection_fps,skipped_fps,service",
}, },
], ],
{ {
@ -69,38 +60,19 @@ export default function CameraMetrics({
} }
if (statsHistory.length == 0) { if (statsHistory.length == 0) {
startTransition(() => setStatsHistory(initialStats)); setStatsHistory(initialStats);
return; return;
} }
if (!isActive || !updatedStats) { if (!updatedStats) {
return; return;
} }
if (updatedStats.service.last_updated > lastUpdated) { if (updatedStats.service.last_updated > lastUpdated) {
setStatsHistory([...statsHistory.slice(1), updatedStats]); setStatsHistory([...statsHistory.slice(1), updatedStats]);
setLastUpdated(updatedStats.service.last_updated); setLastUpdated(Date.now() / 1000);
} }
}, [ }, [initialStats, updatedStats, statsHistory, lastUpdated, setLastUpdated]);
initialStats,
updatedStats,
statsHistory,
lastUpdated,
setLastUpdated,
isActive,
]);
useEffect(() => {
if (isActive && statsHistory.length > 0) {
refreshStats().then((freshStats) => {
if (freshStats && freshStats.length > 0) {
setStatsHistory(freshStats);
}
});
}
// only re-fetch when tab becomes active, not on data changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive]);
// timestamps // timestamps
@ -196,15 +168,15 @@ export default function CameraMetrics({
series[key]["ffmpeg"].data.push({ series[key]["ffmpeg"].data.push({
x: statsIdx, x: statsIdx,
y: camStats.ffmpeg_cpu ?? "0", y: stats.cpu_usages[camStats.ffmpeg_pid.toString()]?.cpu ?? 0.0,
}); });
series[key]["capture"].data.push({ series[key]["capture"].data.push({
x: statsIdx, x: statsIdx,
y: camStats.capture_cpu ?? "0", y: stats.cpu_usages[camStats.capture_pid?.toString()]?.cpu ?? 0,
}); });
series[key]["detect"].data.push({ series[key]["detect"].data.push({
x: statsIdx, x: statsIdx,
y: camStats.detect_cpu ?? "0", y: stats.cpu_usages[camStats.pid?.toString()]?.cpu,
}); });
}); });
}); });
@ -289,7 +261,6 @@ export default function CameraMetrics({
dataLabels={["camera", "detect", "skipped"]} dataLabels={["camera", "detect", "skipped"]}
updateTimes={updateTimes} updateTimes={updateTimes}
data={overallFpsSeries} data={overallFpsSeries}
isActive={isActive}
/> />
</div> </div>
) : ( ) : (
@ -301,9 +272,10 @@ export default function CameraMetrics({
Object.values(config.cameras).map((camera) => { Object.values(config.cameras).map((camera) => {
if (camera.enabled) { if (camera.enabled) {
return ( return (
<Fragment key={camera.name}> <>
{probeCameraName == camera.name && ( {probeCameraName == camera.name && (
<CameraInfoDialog <CameraInfoDialog
key={camera.name}
camera={camera} camera={camera}
showCameraInfoDialog={showCameraInfoDialog} showCameraInfoDialog={showCameraInfoDialog}
setShowCameraInfoDialog={setShowCameraInfoDialog} setShowCameraInfoDialog={setShowCameraInfoDialog}
@ -373,7 +345,6 @@ export default function CameraMetrics({
data={Object.values( data={Object.values(
cameraCpuSeries[camera.name] || {}, cameraCpuSeries[camera.name] || {},
)} )}
isActive={isActive}
/> />
</div> </div>
) : ( ) : (
@ -392,7 +363,6 @@ export default function CameraMetrics({
data={Object.values( data={Object.values(
cameraFpsSeries[camera.name] || {}, cameraFpsSeries[camera.name] || {},
)} )}
isActive={isActive}
/> />
</div> </div>
) : ( ) : (
@ -400,7 +370,7 @@ export default function CameraMetrics({
)} )}
</div> </div>
</div> </div>
</Fragment> </>
); );
} }

View File

@ -1,12 +1,6 @@
import useSWR from "swr"; import useSWR from "swr";
import { FrigateStats } from "@/types/stats"; import { FrigateStats } from "@/types/stats";
import { import { useCallback, useEffect, useMemo, useState } from "react";
startTransition,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useFrigateStats } from "@/api/ws"; import { useFrigateStats } from "@/api/ws";
import { EmbeddingThreshold, GenAIThreshold, Threshold } from "@/types/graph"; import { EmbeddingThreshold, GenAIThreshold, Threshold } from "@/types/graph";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@ -18,18 +12,16 @@ import { EventsPerSecondsLineGraph } from "@/components/graph/LineGraph";
type EnrichmentMetricsProps = { type EnrichmentMetricsProps = {
lastUpdated: number; lastUpdated: number;
setLastUpdated: (last: number) => void; setLastUpdated: (last: number) => void;
isActive: boolean;
}; };
export default function EnrichmentMetrics({ export default function EnrichmentMetrics({
lastUpdated, lastUpdated,
setLastUpdated, setLastUpdated,
isActive,
}: EnrichmentMetricsProps) { }: EnrichmentMetricsProps) {
// stats // stats
const { t } = useTranslation(["views/system"]); const { t } = useTranslation(["views/system"]);
const { data: initialStats, mutate: refreshStats } = useSWR<FrigateStats[]>( const { data: initialStats } = useSWR<FrigateStats[]>(
["stats/history", { keys: "embeddings,service.last_updated" }], ["stats/history", { keys: "embeddings,service" }],
{ {
revalidateOnFocus: false, revalidateOnFocus: false,
}, },
@ -44,38 +36,19 @@ export default function EnrichmentMetrics({
} }
if (statsHistory.length == 0) { if (statsHistory.length == 0) {
startTransition(() => setStatsHistory(initialStats)); setStatsHistory(initialStats);
return; return;
} }
if (!isActive || !updatedStats) { if (!updatedStats) {
return; return;
} }
if (updatedStats.service.last_updated > lastUpdated) { if (updatedStats.service.last_updated > lastUpdated) {
setStatsHistory([...statsHistory.slice(1), updatedStats]); setStatsHistory([...statsHistory.slice(1), updatedStats]);
setLastUpdated(updatedStats.service.last_updated); setLastUpdated(Date.now() / 1000);
} }
}, [ }, [initialStats, updatedStats, statsHistory, lastUpdated, setLastUpdated]);
initialStats,
updatedStats,
statsHistory,
lastUpdated,
setLastUpdated,
isActive,
]);
useEffect(() => {
if (isActive && statsHistory.length > 0) {
refreshStats().then((freshStats) => {
if (freshStats && freshStats.length > 0) {
setStatsHistory(freshStats);
}
});
}
// only re-fetch when tab becomes active, not on data changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive]);
const getThreshold = useCallback((key: string) => { const getThreshold = useCallback((key: string) => {
if (key.includes("description")) { if (key.includes("description")) {
@ -232,7 +205,6 @@ export default function EnrichmentMetrics({
threshold={group.speedSeries.metrics} threshold={group.speedSeries.metrics}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[group.speedSeries]} data={[group.speedSeries]}
isActive={isActive}
/> />
)} )}
{group.eventsSeries && ( {group.eventsSeries && (
@ -243,7 +215,6 @@ export default function EnrichmentMetrics({
name={t("enrichments.infPerSecond")} name={t("enrichments.infPerSecond")}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[group.eventsSeries]} data={[group.eventsSeries]}
isActive={isActive}
/> />
)} )}
</div> </div>

View File

@ -1,6 +1,6 @@
import useSWR from "swr"; import useSWR from "swr";
import { FrigateStats, GpuInfo } from "@/types/stats"; import { FrigateStats, GpuInfo } from "@/types/stats";
import { startTransition, useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useFrigateStats } from "@/api/ws"; import { useFrigateStats } from "@/api/ws";
import { import {
DetectorCpuThreshold, DetectorCpuThreshold,
@ -26,12 +26,10 @@ import { CiCircleAlert } from "react-icons/ci";
type GeneralMetricsProps = { type GeneralMetricsProps = {
lastUpdated: number; lastUpdated: number;
setLastUpdated: (last: number) => void; setLastUpdated: (last: number) => void;
isActive: boolean;
}; };
export default function GeneralMetrics({ export default function GeneralMetrics({
lastUpdated, lastUpdated,
setLastUpdated, setLastUpdated,
isActive,
}: GeneralMetricsProps) { }: GeneralMetricsProps) {
// extra info // extra info
const { t } = useTranslation(["views/system"]); const { t } = useTranslation(["views/system"]);
@ -39,12 +37,10 @@ export default function GeneralMetrics({
// stats // stats
const { data: initialStats, mutate: refreshStats } = useSWR<FrigateStats[]>( const { data: initialStats } = useSWR<FrigateStats[]>(
[ [
"stats/history", "stats/history",
{ { keys: "cpu_usages,detectors,gpu_usages,npu_usages,processes,service" },
keys: "detectors.inference_speed,detectors.temperature,detectors.cpu,detectors.mem,gpu_usages,npu_usages,processes.cpu,processes.mem,service.last_updated",
},
], ],
{ {
revalidateOnFocus: false, revalidateOnFocus: false,
@ -60,38 +56,19 @@ export default function GeneralMetrics({
} }
if (statsHistory.length == 0) { if (statsHistory.length == 0) {
startTransition(() => setStatsHistory(initialStats)); setStatsHistory(initialStats);
return; return;
} }
if (!isActive || !updatedStats) { if (!updatedStats) {
return; return;
} }
if (updatedStats.service.last_updated > lastUpdated) { if (updatedStats.service.last_updated > lastUpdated) {
setStatsHistory([...statsHistory.slice(1), updatedStats]); setStatsHistory([...statsHistory.slice(1), updatedStats]);
setLastUpdated(updatedStats.service.last_updated); setLastUpdated(Date.now() / 1000);
} }
}, [ }, [initialStats, updatedStats, statsHistory, lastUpdated, setLastUpdated]);
initialStats,
updatedStats,
statsHistory,
lastUpdated,
setLastUpdated,
isActive,
]);
useEffect(() => {
if (isActive && statsHistory.length > 0) {
refreshStats().then((freshStats) => {
if (freshStats && freshStats.length > 0) {
setStatsHistory(freshStats);
}
});
}
// only re-fetch when tab becomes active, not on data changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive]);
const [canGetGpuInfo, gpuType] = useMemo<[boolean, GpuInfo]>(() => { const [canGetGpuInfo, gpuType] = useMemo<[boolean, GpuInfo]>(() => {
let vaCount = 0; let vaCount = 0;
@ -99,7 +76,7 @@ export default function GeneralMetrics({
statsHistory.length > 0 && statsHistory.length > 0 &&
Object.keys(statsHistory[0]?.gpu_usages ?? {}).forEach((key) => { Object.keys(statsHistory[0]?.gpu_usages ?? {}).forEach((key) => {
if (key == "amd-vaapi" || key == "intel-gpu") { if (key == "amd-vaapi" || key == "intel-vaapi" || key == "intel-qsv") {
vaCount += 1; vaCount += 1;
} }
@ -204,7 +181,7 @@ export default function GeneralMetrics({
series[key] = { name: key, data: [] }; series[key] = { name: key, data: [] };
} }
const data = detStats.cpu; const data = stats.cpu_usages[detStats.pid.toString()]?.cpu;
if (data != undefined) { if (data != undefined) {
series[key].data.push({ series[key].data.push({
@ -236,12 +213,10 @@ export default function GeneralMetrics({
series[key] = { name: key, data: [] }; series[key] = { name: key, data: [] };
} }
if (detStats.mem != undefined) { series[key].data.push({
series[key].data.push({ x: statsIdx + 1,
x: statsIdx + 1, y: stats.cpu_usages[detStats.pid.toString()].mem,
y: detStats.mem, });
});
}
}); });
}); });
return Object.values(series); return Object.values(series);
@ -290,7 +265,7 @@ export default function GeneralMetrics({
if ( if (
Object.keys(statsHistory?.at(0)?.gpu_usages ?? {}).length == 1 && Object.keys(statsHistory?.at(0)?.gpu_usages ?? {}).length == 1 &&
Object.keys(statsHistory?.at(0)?.gpu_usages ?? {})[0] === "intel-gpu" Object.keys(statsHistory?.at(0)?.gpu_usages ?? {})[0].includes("intel")
) { ) {
// intel gpu stats do not support memory // intel gpu stats do not support memory
return undefined; return undefined;
@ -359,43 +334,6 @@ export default function GeneralMetrics({
return Object.keys(series).length > 0 ? Object.values(series) : undefined; return Object.keys(series).length > 0 ? Object.values(series) : undefined;
}, [statsHistory]); }, [statsHistory]);
const gpuComputeSeries = useMemo(() => {
if (!statsHistory) {
return [];
}
const series: {
[key: string]: { name: string; data: { x: number; y: string }[] };
} = {};
let hasValidGpu = false;
statsHistory.forEach((stats, statsIdx) => {
if (!stats) {
return;
}
Object.entries(stats.gpu_usages || {}).forEach(([key, stats]) => {
if (!(key in series)) {
series[key] = { name: key, data: [] };
}
if (stats.compute) {
hasValidGpu = true;
series[key].data.push({
x: statsIdx + 1,
y: stats.compute.slice(0, -1),
});
}
});
});
if (!hasValidGpu) {
return [];
}
return Object.keys(series).length > 0 ? Object.values(series) : undefined;
}, [statsHistory]);
const gpuDecSeries = useMemo(() => { const gpuDecSeries = useMemo(() => {
if (!statsHistory) { if (!statsHistory) {
return []; return [];
@ -471,7 +409,9 @@ export default function GeneralMetrics({
} }
const gpuKeys = Object.keys(statsHistory[0]?.gpu_usages ?? {}); const gpuKeys = Object.keys(statsHistory[0]?.gpu_usages ?? {});
const hasIntelGpu = gpuKeys.some((key) => key === "intel-gpu"); const hasIntelGpu = gpuKeys.some(
(key) => key === "intel-vaapi" || key === "intel-qsv",
);
if (!hasIntelGpu) { if (!hasIntelGpu) {
return false; return false;
@ -487,7 +427,7 @@ export default function GeneralMetrics({
} }
Object.entries(stats.gpu_usages || {}).forEach(([key, gpuStats]) => { Object.entries(stats.gpu_usages || {}).forEach(([key, gpuStats]) => {
if (key === "intel-gpu") { if (key === "intel-vaapi" || key === "intel-qsv") {
if (gpuStats.gpu) { if (gpuStats.gpu) {
hasDataPoints = true; hasDataPoints = true;
const gpuValue = parseFloat(gpuStats.gpu.slice(0, -1)); const gpuValue = parseFloat(gpuStats.gpu.slice(0, -1));
@ -606,18 +546,22 @@ export default function GeneralMetrics({
} }
Object.entries(stats.processes).forEach(([key, procStats]) => { Object.entries(stats.processes).forEach(([key, procStats]) => {
if (!(key in series)) { if (procStats.pid.toString() in stats.cpu_usages) {
series[key] = { if (!(key in series)) {
name: t(`general.otherProcesses.series.${key}`), series[key] = {
data: [], name: t(`general.otherProcesses.series.${key}`),
}; data: [],
} };
}
if (procStats.cpu != undefined) { const data = stats.cpu_usages[procStats.pid.toString()]?.cpu;
series[key].data.push({
x: statsIdx + 1, if (data != undefined) {
y: procStats.cpu, series[key].data.push({
}); x: statsIdx + 1,
y: data,
});
}
} }
}); });
}); });
@ -639,18 +583,22 @@ export default function GeneralMetrics({
} }
Object.entries(stats.processes).forEach(([key, procStats]) => { Object.entries(stats.processes).forEach(([key, procStats]) => {
if (!(key in series)) { if (procStats.pid.toString() in stats.cpu_usages) {
series[key] = { if (!(key in series)) {
name: t(`general.otherProcesses.series.${key}`), series[key] = {
data: [], name: t(`general.otherProcesses.series.${key}`),
}; data: [],
} };
}
if (procStats.mem) { const data = stats.cpu_usages[procStats.pid.toString()]?.mem;
series[key].data.push({
x: statsIdx + 1, if (data) {
y: procStats.mem, series[key].data.push({
}); x: statsIdx + 1,
y: data,
});
}
} }
}); });
}); });
@ -687,7 +635,6 @@ export default function GeneralMetrics({
threshold={InferenceThreshold} threshold={InferenceThreshold}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[series]} data={[series]}
isActive={isActive}
/> />
))} ))}
</div> </div>
@ -710,7 +657,6 @@ export default function GeneralMetrics({
threshold={DetectorTempThreshold} threshold={DetectorTempThreshold}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[series]} data={[series]}
isActive={isActive}
/> />
))} ))}
</div> </div>
@ -749,7 +695,6 @@ export default function GeneralMetrics({
threshold={DetectorCpuThreshold} threshold={DetectorCpuThreshold}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[series]} data={[series]}
isActive={isActive}
/> />
))} ))}
</div> </div>
@ -768,7 +713,6 @@ export default function GeneralMetrics({
threshold={DetectorMemThreshold} threshold={DetectorMemThreshold}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[series]} data={[series]}
isActive={isActive}
/> />
))} ))}
</div> </div>
@ -800,9 +744,8 @@ export default function GeneralMetrics({
className={cn( className={cn(
"mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2", "mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2",
gpuTempSeries?.length && "md:grid-cols-3", gpuTempSeries?.length && "md:grid-cols-3",
(gpuEncSeries?.length || gpuComputeSeries?.length) && gpuEncSeries?.length && "xl:grid-cols-4",
"xl:grid-cols-4", gpuEncSeries?.length &&
(gpuEncSeries?.length || gpuComputeSeries?.length) &&
gpuTempSeries?.length && gpuTempSeries?.length &&
"3xl:grid-cols-5", "3xl:grid-cols-5",
)} )}
@ -861,7 +804,6 @@ export default function GeneralMetrics({
threshold={GPUUsageThreshold} threshold={GPUUsageThreshold}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[series]} data={[series]}
isActive={isActive}
/> />
))} ))}
</div> </div>
@ -884,7 +826,6 @@ export default function GeneralMetrics({
threshold={GPUMemThreshold} threshold={GPUMemThreshold}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[series]} data={[series]}
isActive={isActive}
/> />
))} ))}
</div> </div>
@ -909,32 +850,6 @@ export default function GeneralMetrics({
threshold={GPUMemThreshold} threshold={GPUMemThreshold}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[series]} data={[series]}
isActive={isActive}
/>
))}
</div>
)}
</>
) : (
<Skeleton className="aspect-video w-full" />
)}
{statsHistory.length != 0 ? (
<>
{gpuComputeSeries && gpuComputeSeries?.length != 0 && (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">
{t("general.hardwareInfo.gpuCompute")}
</div>
{gpuComputeSeries.map((series) => (
<ThresholdBarGraph
key={series.name}
graphId={`${series.name}-compute`}
unit="%"
name={series.name}
threshold={GPUMemThreshold}
updateTimes={updateTimes}
data={[series]}
isActive={isActive}
/> />
))} ))}
</div> </div>
@ -959,7 +874,6 @@ export default function GeneralMetrics({
threshold={GPUMemThreshold} threshold={GPUMemThreshold}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[series]} data={[series]}
isActive={isActive}
/> />
))} ))}
</div> </div>
@ -984,7 +898,6 @@ export default function GeneralMetrics({
threshold={DetectorTempThreshold} threshold={DetectorTempThreshold}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[series]} data={[series]}
isActive={isActive}
/> />
))} ))}
</div> </div>
@ -1010,7 +923,6 @@ export default function GeneralMetrics({
threshold={GPUUsageThreshold} threshold={GPUUsageThreshold}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[series]} data={[series]}
isActive={isActive}
/> />
))} ))}
</div> </div>
@ -1033,7 +945,6 @@ export default function GeneralMetrics({
threshold={DetectorTempThreshold} threshold={DetectorTempThreshold}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[series]} data={[series]}
isActive={isActive}
/> />
))} ))}
</div> </div>
@ -1067,7 +978,6 @@ export default function GeneralMetrics({
threshold={DetectorCpuThreshold} threshold={DetectorCpuThreshold}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[series]} data={[series]}
isActive={isActive}
/> />
))} ))}
</div> </div>
@ -1087,7 +997,6 @@ export default function GeneralMetrics({
threshold={DetectorMemThreshold} threshold={DetectorMemThreshold}
updateTimes={updateTimes} updateTimes={updateTimes}
data={[series]} data={[series]}
isActive={isActive}
/> />
))} ))}
</div> </div>

View File

@ -1,7 +1,7 @@
import { CombinedStorageGraph } from "@/components/graph/CombinedStorageGraph"; import { CombinedStorageGraph } from "@/components/graph/CombinedStorageGraph";
import { StorageGraph } from "@/components/graph/StorageGraph"; import { StorageGraph } from "@/components/graph/StorageGraph";
import { FrigateStats } from "@/types/stats"; import { FrigateStats } from "@/types/stats";
import { useEffect, useMemo } from "react"; import { useMemo } from "react";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@ -10,11 +10,7 @@ import {
import useSWR from "swr"; import useSWR from "swr";
import { CiCircleAlert } from "react-icons/ci"; import { CiCircleAlert } from "react-icons/ci";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { import { useFormattedTimestamp, useTimezone } from "@/hooks/use-date-utils";
useFormattedTimestamp,
useTimeFormat,
useTimezone,
} from "@/hooks/use-date-utils";
import { RecordingsSummary } from "@/types/review"; import { RecordingsSummary } from "@/types/review";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TZDate } from "react-day-picker"; import { TZDate } from "react-day-picker";
@ -60,14 +56,9 @@ export default function StorageMetrics({
Object.values(cameraStorage).forEach( Object.values(cameraStorage).forEach(
(cam) => (totalStorage.camera += cam.usage), (cam) => (totalStorage.camera += cam.usage),
); );
setLastUpdated(Date.now() / 1000);
return totalStorage; return totalStorage;
}, [cameraStorage, stats]); }, [cameraStorage, stats, setLastUpdated]);
useEffect(() => {
if (totalStorage) {
setLastUpdated(Math.floor(Date.now() / 1000));
}
}, [totalStorage, setLastUpdated]);
// recordings summary // recordings summary
@ -85,7 +76,7 @@ export default function StorageMetrics({
: null; : null;
}, [recordingsSummary, timezone]); }, [recordingsSummary, timezone]);
const timeFormat = useTimeFormat(config); const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
const format = useMemo(() => { const format = useMemo(() => {
return t(`time.formattedTimestampMonthDayYear.${timeFormat}`, { return t(`time.formattedTimestampMonthDayYear.${timeFormat}`, {
ns: "common", ns: "common",