mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 23:15:28 +03:00
Compare commits
8 Commits
29ca18c24c
...
f0a6626c6a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0a6626c6a | ||
|
|
953d244c52 | ||
|
|
257dae11c1 | ||
|
|
f44f485f48 | ||
|
|
f002513d36 | ||
|
|
b04b1d0dc8 | ||
|
|
decc8aa391 | ||
|
|
831cfc2444 |
@ -54,6 +54,28 @@ 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)
|
||||||
@ -372,13 +394,30 @@ class CameraState:
|
|||||||
|
|
||||||
updated_obj.last_updated = frame_time
|
updated_obj.last_updated = frame_time
|
||||||
|
|
||||||
# if it has been more than 5 seconds since the last thumb update
|
# Determine the staleness threshold for publishing updates.
|
||||||
# and the last update is greater than the last publish or
|
# Fast-track to 1s for objects in the optimal size range for
|
||||||
# the object has changed significantly or
|
# secondary face/LPR recognition that don't yet have a sub_label.
|
||||||
# the object moved enough to update the path
|
obj_area = updated_obj.obj_data.get("area", 0)
|
||||||
|
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 > 5
|
frame_time - updated_obj.last_published > publish_threshold
|
||||||
and updated_obj.last_updated > updated_obj.last_published
|
and updated_obj.last_updated > updated_obj.last_published
|
||||||
)
|
)
|
||||||
or significant_update
|
or significant_update
|
||||||
@ -389,6 +428,18 @@ 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]
|
||||||
|
|||||||
@ -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(expanded_box.astype(int)) # type: ignore[return-value]
|
return tuple(int(x) for x in expanded_box) # type: ignore[return-value]
|
||||||
else:
|
else:
|
||||||
return None # No detection above the threshold
|
return None # No detection above the threshold
|
||||||
|
|
||||||
|
|||||||
@ -149,10 +149,13 @@ 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"] - buffer_extension,
|
final_data["start_time"],
|
||||||
final_data["end_time"] + buffer_extension,
|
final_data["end_time"],
|
||||||
height=480, # Use 480p for good balance between quality and token usage
|
height=480, # Use 480p for good balance between quality and token usage
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -249,10 +249,9 @@ class PreviewRecorder:
|
|||||||
"v2": v2,
|
"v2": v2,
|
||||||
}
|
}
|
||||||
|
|
||||||
# end segment at end of hour
|
# end segment at end of hour (use UTC to avoid DST issues)
|
||||||
self.segment_end = (
|
self.segment_end = (
|
||||||
(datetime.datetime.now() + datetime.timedelta(hours=1))
|
(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1))
|
||||||
.astimezone(datetime.timezone.utc)
|
|
||||||
.replace(minute=0, second=0, microsecond=0)
|
.replace(minute=0, second=0, microsecond=0)
|
||||||
.timestamp()
|
.timestamp()
|
||||||
)
|
)
|
||||||
@ -264,8 +263,7 @@ class PreviewRecorder:
|
|||||||
|
|
||||||
# check for existing items in cache
|
# check for existing items in cache
|
||||||
start_ts = (
|
start_ts = (
|
||||||
datetime.datetime.now()
|
datetime.datetime.now(datetime.timezone.utc)
|
||||||
.astimezone(datetime.timezone.utc)
|
|
||||||
.replace(minute=0, second=0, microsecond=0)
|
.replace(minute=0, second=0, microsecond=0)
|
||||||
.timestamp()
|
.timestamp()
|
||||||
)
|
)
|
||||||
@ -299,8 +297,10 @@ 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))
|
(
|
||||||
.astimezone(datetime.timezone.utc)
|
datetime.datetime.fromtimestamp(frame_time, tz=datetime.timezone.utc)
|
||||||
|
+ datetime.timedelta(hours=1)
|
||||||
|
)
|
||||||
.replace(minute=0, second=0, microsecond=0)
|
.replace(minute=0, second=0, microsecond=0)
|
||||||
.timestamp()
|
.timestamp()
|
||||||
)
|
)
|
||||||
|
|||||||
@ -52,18 +52,66 @@ 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 = {}
|
selected: dict[str, Any] = {}
|
||||||
|
|
||||||
for k in keys:
|
for k in top_level_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
|
||||||
|
|||||||
@ -355,16 +355,37 @@ 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(
|
||||||
|
|||||||
@ -261,20 +261,22 @@ 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:
|
elif "qsv" in args or ("vaapi" in args and not is_vaapi_amd_driver()):
|
||||||
if not config.telemetry.stats.intel_gpu_stats:
|
if not config.telemetry.stats.intel_gpu_stats:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# intel QSV GPU
|
if "intel-gpu" not in stats:
|
||||||
intel_usage = get_intel_gpu_stats(config.telemetry.stats.intel_gpu_device)
|
# 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:
|
if intel_usage is not None:
|
||||||
stats["intel-qsv"] = intel_usage or {"gpu": "", "mem": ""}
|
stats["intel-gpu"] = intel_usage or {"gpu": "", "mem": ""}
|
||||||
else:
|
else:
|
||||||
stats["intel-qsv"] = {"gpu": "", "mem": ""}
|
stats["intel-gpu"] = {"gpu": "", "mem": ""}
|
||||||
hwaccel_errors.append(args)
|
hwaccel_errors.append(args)
|
||||||
elif "vaapi" in args:
|
elif "vaapi" in args:
|
||||||
if is_vaapi_amd_driver():
|
|
||||||
if not config.telemetry.stats.amd_gpu_stats:
|
if not config.telemetry.stats.amd_gpu_stats:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -286,20 +288,6 @@ async def set_gpu_stats(
|
|||||||
else:
|
else:
|
||||||
stats["amd-vaapi"] = {"gpu": "", "mem": ""}
|
stats["amd-vaapi"] = {"gpu": "", "mem": ""}
|
||||||
hwaccel_errors.append(args)
|
hwaccel_errors.append(args)
|
||||||
else:
|
|
||||||
if not config.telemetry.stats.intel_gpu_stats:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# intel VAAPI GPU
|
|
||||||
intel_usage = get_intel_gpu_stats(
|
|
||||||
config.telemetry.stats.intel_gpu_device
|
|
||||||
)
|
|
||||||
|
|
||||||
if intel_usage is not None:
|
|
||||||
stats["intel-vaapi"] = intel_usage or {"gpu": "", "mem": ""}
|
|
||||||
else:
|
|
||||||
stats["intel-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()
|
||||||
|
|
||||||
@ -510,4 +498,30 @@ 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
|
||||||
|
|||||||
@ -39,8 +39,12 @@ 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)
|
||||||
print(f"the intel stats are {intel_stats}")
|
# rc6 values: 47.844741 and 100.0 → avg 73.92 → gpu = 100 - 73.92 = 26.08%
|
||||||
|
# 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": "1.13%",
|
"gpu": "26.08%",
|
||||||
"mem": "-%",
|
"mem": "-%",
|
||||||
|
"compute": "0.0%",
|
||||||
|
"dec": "2.27%",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,6 +61,7 @@ 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
|
||||||
@ -278,6 +279,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
@ -265,14 +265,30 @@ 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] = {}
|
||||||
|
|
||||||
# render is used for qsv
|
# rc6 residency for overall GPU usage
|
||||||
|
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:])
|
||||||
@ -280,11 +296,9 @@ 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:
|
||||||
render_avg = sum(render) / len(render)
|
results["compute"] = f"{round(sum(render) / len(render), 2)}%"
|
||||||
else:
|
|
||||||
render_avg = 1
|
|
||||||
|
|
||||||
# video is used for vaapi
|
# Video engines are the fixed-function decode engines
|
||||||
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:])
|
||||||
@ -292,12 +306,8 @@ 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:
|
||||||
video_avg = sum(video) / len(video)
|
results["dec"] = f"{round(sum(video) / len(video), 2)}%"
|
||||||
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 = [
|
||||||
@ -336,10 +346,18 @@ 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] = {}
|
||||||
render = {"global": []}
|
rc6_values = []
|
||||||
video = {"global": []}
|
render_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:
|
||||||
@ -347,47 +365,52 @@ 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 and len(clients):
|
if clients:
|
||||||
for client_block in clients.values():
|
for client_block in clients.values():
|
||||||
key = client_block["pid"]
|
pid = client_block["pid"]
|
||||||
|
|
||||||
if render.get(key) is None:
|
if pid not in client_usages:
|
||||||
render[key] = []
|
client_usages[pid] = []
|
||||||
video[key] = []
|
|
||||||
|
|
||||||
client_engine = client_block.get("engine-classes", {})
|
# Sum all engine-class busy values for this client
|
||||||
|
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)
|
||||||
|
|
||||||
render_frame = client_engine.get("Render/3D", {}).get("busy")
|
client_usages[pid].append(total_busy)
|
||||||
video_frame = client_engine.get("Video", {}).get("busy")
|
|
||||||
|
|
||||||
if render_frame is not None:
|
# Overall GPU usage from rc6 (idle) residency
|
||||||
render[key].append(float(render_frame))
|
if rc6_values:
|
||||||
|
rc6_avg = sum(rc6_values) / len(rc6_values)
|
||||||
|
results["gpu"] = f"{round(100.0 - rc6_avg, 2)}%"
|
||||||
|
|
||||||
if video_frame is not None:
|
|
||||||
video[key].append(float(video_frame))
|
|
||||||
|
|
||||||
if render["global"] and video["global"]:
|
|
||||||
results["gpu"] = (
|
|
||||||
f"{round(((sum(render['global']) / len(render['global'])) + (sum(video['global']) / len(video['global']))) / 2, 2)}%"
|
|
||||||
)
|
|
||||||
results["mem"] = "-%"
|
results["mem"] = "-%"
|
||||||
|
|
||||||
if len(render.keys()) > 1:
|
# Compute: Render/3D engine (compute/shader workloads and QSV encode)
|
||||||
|
if render_global:
|
||||||
|
results["compute"] = f"{round(sum(render_global) / len(render_global), 2)}%"
|
||||||
|
|
||||||
|
# Decoder: Video engine (fixed-function codec)
|
||||||
|
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 key in render.keys():
|
for pid, samples in client_usages.items():
|
||||||
if key == "global" or not render[key] or not video[key]:
|
if samples:
|
||||||
continue
|
results["clients"][pid] = (
|
||||||
|
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
|
||||||
|
|||||||
@ -1433,8 +1433,7 @@
|
|||||||
},
|
},
|
||||||
"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}}"
|
||||||
@ -1606,5 +1605,38 @@
|
|||||||
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,6 +78,7 @@
|
|||||||
"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": {
|
||||||
@ -188,6 +189,7 @@
|
|||||||
"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"
|
||||||
|
|||||||
@ -116,8 +116,7 @@ export default function Statusbar() {
|
|||||||
case "amd-vaapi":
|
case "amd-vaapi":
|
||||||
gpuTitle = "AMD GPU";
|
gpuTitle = "AMD GPU";
|
||||||
break;
|
break;
|
||||||
case "intel-vaapi":
|
case "intel-gpu":
|
||||||
case "intel-qsv":
|
|
||||||
gpuTitle = "Intel GPU";
|
gpuTitle = "Intel GPU";
|
||||||
break;
|
break;
|
||||||
case "rockchip":
|
case "rockchip":
|
||||||
|
|||||||
@ -8,6 +8,7 @@ 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
|
||||||
|
|
||||||
@ -72,7 +73,7 @@ export function AudioLevelGraph({ cameraName }: AudioLevelGraphProps) {
|
|||||||
return [last.rms, last.dBFS];
|
return [last.rms, last.dBFS];
|
||||||
}, [audioData]);
|
}, [audioData]);
|
||||||
|
|
||||||
const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
const timeFormat = useTimeFormat(config);
|
||||||
const formatString = useMemo(
|
const formatString = useMemo(
|
||||||
() =>
|
() =>
|
||||||
t(`time.formattedTimestampHourMinuteSecond.${timeFormat}`, {
|
t(`time.formattedTimestampHourMinuteSecond.${timeFormat}`, {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ 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 = {
|
||||||
@ -26,7 +27,7 @@ export function DownloadVideoButton({
|
|||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const locale = useDateLocale();
|
const locale = useDateLocale();
|
||||||
|
|
||||||
const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
const timeFormat = useTimeFormat(config);
|
||||||
const format = useMemo(() => {
|
const format = useMemo(() => {
|
||||||
return t(`time.formattedTimestampFilename.${timeFormat}`, { ns: "common" });
|
return t(`time.formattedTimestampFilename.${timeFormat}`, { ns: "common" });
|
||||||
}, [t, timeFormat]);
|
}, [t, timeFormat]);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp, use24HourTime } 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,9 +55,10 @@ 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,
|
||||||
config?.ui.time_format == "24hour"
|
is24Hour
|
||||||
? 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,
|
||||||
|
|||||||
@ -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 } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp, use24HourTime } 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,9 +29,10 @@ 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,
|
||||||
config?.ui.time_format == "24hour"
|
is24Hour
|
||||||
? 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,
|
||||||
|
|||||||
48
web/src/components/config-form/ConfigFieldMessage.tsx
Normal file
48
web/src/components/config-form/ConfigFieldMessage.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
web/src/components/config-form/ConfigMessageBanner.tsx
Normal file
52
web/src/components/config-form/ConfigMessageBanner.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,21 @@ 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",
|
||||||
|
|||||||
@ -3,6 +3,19 @@ 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"],
|
||||||
|
|||||||
@ -3,6 +3,20 @@ 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: [],
|
||||||
|
|||||||
@ -3,6 +3,21 @@ 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",
|
||||||
|
|||||||
@ -3,6 +3,26 @@ 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: [],
|
||||||
|
|||||||
@ -3,6 +3,28 @@ 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",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3,6 +3,19 @@ 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",
|
||||||
|
|||||||
@ -3,6 +3,45 @@ 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",
|
||||||
@ -35,8 +74,6 @@ 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: {
|
||||||
|
|||||||
@ -3,6 +3,17 @@ 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",
|
||||||
|
|||||||
@ -1,5 +1,39 @@
|
|||||||
|
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>;
|
||||||
|
|||||||
@ -43,6 +43,7 @@ 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";
|
||||||
@ -752,6 +753,7 @@ 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" });
|
||||||
@ -760,8 +762,7 @@ 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:
|
date_format: is24Hour
|
||||||
config?.ui.time_format == "24hour"
|
|
||||||
? t("time.formattedTimestampMonthDayHourMinute.24hour", {
|
? t("time.formattedTimestampMonthDayHourMinute.24hour", {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
})
|
})
|
||||||
|
|||||||
@ -71,6 +71,13 @@ 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 */
|
||||||
@ -100,6 +107,10 @@ 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 {
|
||||||
@ -536,6 +547,65 @@ 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;
|
||||||
@ -874,6 +944,7 @@ 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}
|
||||||
@ -885,7 +956,7 @@ export function ConfigSection({
|
|||||||
hiddenFields={effectiveHiddenFields}
|
hiddenFields={effectiveHiddenFields}
|
||||||
advancedFields={sectionConfig.advancedFields}
|
advancedFields={sectionConfig.advancedFields}
|
||||||
liveValidate={sectionConfig.liveValidate}
|
liveValidate={sectionConfig.liveValidate}
|
||||||
uiSchema={sectionConfig.uiSchema}
|
uiSchema={effectiveUiSchema}
|
||||||
disabled={disabled || isSaving}
|
disabled={disabled || isSaving}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
showSubmit={false}
|
showSubmit={false}
|
||||||
|
|||||||
@ -28,6 +28,7 @@ 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";
|
||||||
|
|
||||||
@ -382,6 +383,46 @@ 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,
|
||||||
@ -600,6 +641,7 @@ 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()}
|
||||||
@ -607,6 +649,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
{errors}
|
{errors}
|
||||||
{help}
|
{help}
|
||||||
</div>
|
</div>
|
||||||
|
{afterMessagesContent}
|
||||||
{afterContent}
|
{afterContent}
|
||||||
</div>
|
</div>
|
||||||
</WrapIfAdditionalTemplate>
|
</WrapIfAdditionalTemplate>
|
||||||
|
|||||||
@ -45,8 +45,6 @@ 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[] {
|
||||||
@ -131,11 +129,6 @@ 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("");
|
||||||
@ -215,12 +208,6 @@ 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>
|
||||||
|
|||||||
@ -2,12 +2,13 @@ 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 } from "react";
|
import { useCallback, useEffect, useMemo, useRef } 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"];
|
||||||
|
|
||||||
@ -17,6 +18,7 @@ type CameraLineGraphProps = {
|
|||||||
dataLabels: string[];
|
dataLabels: string[];
|
||||||
updateTimes: number[];
|
updateTimes: number[];
|
||||||
data: ApexAxisChartSeries;
|
data: ApexAxisChartSeries;
|
||||||
|
isActive?: boolean;
|
||||||
};
|
};
|
||||||
export function CameraLineGraph({
|
export function CameraLineGraph({
|
||||||
graphId,
|
graphId,
|
||||||
@ -24,6 +26,7 @@ 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", {
|
||||||
@ -46,7 +49,7 @@ export function CameraLineGraph({
|
|||||||
|
|
||||||
const locale = useDateLocale();
|
const locale = useDateLocale();
|
||||||
|
|
||||||
const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
const timeFormat = useTimeFormat(config);
|
||||||
const format = useMemo(() => {
|
const format = useMemo(() => {
|
||||||
return t(`time.formattedTimestampHourMinute.${timeFormat}`, {
|
return t(`time.formattedTimestampHourMinute.${timeFormat}`, {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
@ -134,6 +137,16 @@ 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 && (
|
||||||
@ -166,6 +179,7 @@ type EventsPerSecondLineGraphProps = {
|
|||||||
name: string;
|
name: string;
|
||||||
updateTimes: number[];
|
updateTimes: number[];
|
||||||
data: ApexAxisChartSeries;
|
data: ApexAxisChartSeries;
|
||||||
|
isActive?: boolean;
|
||||||
};
|
};
|
||||||
export function EventsPerSecondsLineGraph({
|
export function EventsPerSecondsLineGraph({
|
||||||
graphId,
|
graphId,
|
||||||
@ -173,6 +187,7 @@ 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,
|
||||||
@ -189,7 +204,7 @@ export function EventsPerSecondsLineGraph({
|
|||||||
const locale = useDateLocale();
|
const locale = useDateLocale();
|
||||||
const { t } = useTranslation(["common"]);
|
const { t } = useTranslation(["common"]);
|
||||||
|
|
||||||
const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
const timeFormat = useTimeFormat(config);
|
||||||
const format = useMemo(() => {
|
const format = useMemo(() => {
|
||||||
return t(`time.formattedTimestampHourMinute.${timeFormat}`, {
|
return t(`time.formattedTimestampHourMinute.${timeFormat}`, {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
@ -277,6 +292,16 @@ 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">
|
||||||
|
|||||||
@ -3,11 +3,12 @@ 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 } from "react";
|
import { useCallback, useEffect, useMemo, useRef } 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;
|
||||||
@ -16,6 +17,7 @@ type ThresholdBarGraphProps = {
|
|||||||
threshold: Threshold;
|
threshold: Threshold;
|
||||||
updateTimes: number[];
|
updateTimes: number[];
|
||||||
data: ApexAxisChartSeries;
|
data: ApexAxisChartSeries;
|
||||||
|
isActive?: boolean;
|
||||||
};
|
};
|
||||||
export function ThresholdBarGraph({
|
export function ThresholdBarGraph({
|
||||||
graphId,
|
graphId,
|
||||||
@ -24,6 +26,7 @@ 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", {
|
||||||
@ -51,7 +54,7 @@ export function ThresholdBarGraph({
|
|||||||
const locale = useDateLocale();
|
const locale = useDateLocale();
|
||||||
const { t } = useTranslation(["common"]);
|
const { t } = useTranslation(["common"]);
|
||||||
|
|
||||||
const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
const timeFormat = useTimeFormat(config);
|
||||||
const format = useMemo(() => {
|
const format = useMemo(() => {
|
||||||
return t(`time.formattedTimestampHourMinute.${timeFormat}`, {
|
return t(`time.formattedTimestampHourMinute.${timeFormat}`, {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
@ -173,17 +176,29 @@ export function ThresholdBarGraph({
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const copiedData = [...data];
|
const dataPointCount = data[0].data.length;
|
||||||
const fakeData = [];
|
const fakeData = [];
|
||||||
for (let i = data.length; i < 30; i++) {
|
for (let i = dataPointCount; i < 30; i++) {
|
||||||
fakeData.push({ x: i - 30, y: 0 });
|
fakeData.push({ x: i - 30, y: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-expect-error data types are not obvious
|
const paddedFirst = {
|
||||||
copiedData[0].data = [...fakeData, ...data[0].data];
|
...data[0],
|
||||||
return copiedData;
|
data: [...fakeData, ...data[0].data],
|
||||||
|
};
|
||||||
|
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">
|
||||||
|
|||||||
@ -50,6 +50,7 @@ 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";
|
||||||
@ -80,6 +81,8 @@ 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) {
|
||||||
@ -431,12 +434,8 @@ export default function InputWithTags({
|
|||||||
const [startTime, endTime] = (filterValues as string)
|
const [startTime, endTime] = (filterValues as string)
|
||||||
.replace("-", ",")
|
.replace("-", ",")
|
||||||
.split(",");
|
.split(",");
|
||||||
return `${
|
return `${is24Hour ? startTime : convertTo12Hour(startTime)} - ${
|
||||||
config?.ui.time_format === "24hour"
|
is24Hour ? endTime : convertTo12Hour(endTime)
|
||||||
? 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() + "%";
|
||||||
@ -478,7 +477,7 @@ export default function InputWithTags({
|
|||||||
(filterType === "time_range" &&
|
(filterType === "time_range" &&
|
||||||
isValidTimeRange(
|
isValidTimeRange(
|
||||||
trimmedValue.replace("-", ","),
|
trimmedValue.replace("-", ","),
|
||||||
config?.ui.time_format,
|
resolvedTimeFormat,
|
||||||
)) ||
|
)) ||
|
||||||
((filterType === "min_score" || filterType === "max_score") &&
|
((filterType === "min_score" || filterType === "max_score") &&
|
||||||
!isNaN(Number(trimmedValue)) &&
|
!isNaN(Number(trimmedValue)) &&
|
||||||
@ -495,7 +494,7 @@ export default function InputWithTags({
|
|||||||
? trimmedValue
|
? trimmedValue
|
||||||
.replace("-", ",")
|
.replace("-", ",")
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((time) => to24Hour(time.trim(), config?.ui.time_format))
|
.map((time) => to24Hour(time.trim(), resolvedTimeFormat))
|
||||||
.join(",")
|
.join(",")
|
||||||
: trimmedValue,
|
: trimmedValue,
|
||||||
);
|
);
|
||||||
@ -511,7 +510,7 @@ export default function InputWithTags({
|
|||||||
setCurrentFilterType(null);
|
setCurrentFilterType(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[allSuggestions, createFilter, config],
|
[allSuggestions, createFilter, resolvedTimeFormat],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleInputChange = useCallback(
|
const handleInputChange = useCallback(
|
||||||
@ -598,7 +597,7 @@ export default function InputWithTags({
|
|||||||
suggestion = suggestion
|
suggestion = suggestion
|
||||||
.replace("-", ",")
|
.replace("-", ",")
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((time) => to24Hour(time.trim(), config?.ui.time_format))
|
.map((time) => to24Hour(time.trim(), resolvedTimeFormat))
|
||||||
.join(",");
|
.join(",");
|
||||||
}
|
}
|
||||||
createFilter(currentFilterType, suggestion);
|
createFilter(currentFilterType, suggestion);
|
||||||
@ -627,7 +626,7 @@ export default function InputWithTags({
|
|||||||
|
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
},
|
},
|
||||||
[createFilter, currentFilterType, allSuggestions, config],
|
[createFilter, currentFilterType, allSuggestions, resolvedTimeFormat],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSearch = useCallback(
|
const handleSearch = useCallback(
|
||||||
@ -779,10 +778,7 @@ export default function InputWithTags({
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
{t("filter.tips.desc.step5", {
|
{t("filter.tips.desc.step5", {
|
||||||
exampleTime:
|
exampleTime: is24Hour ? "15:00-16:00" : "3:00PM-4:00PM",
|
||||||
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>
|
||||||
|
|||||||
@ -46,6 +46,7 @@ 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";
|
||||||
@ -247,6 +248,8 @@ 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" });
|
||||||
@ -255,8 +258,7 @@ 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:
|
date_format: is24Hour
|
||||||
config?.ui.time_format == "24hour"
|
|
||||||
? t("time.formattedTimestampMonthDayHourMinute.24hour", {
|
? t("time.formattedTimestampMonthDayHourMinute.24hour", {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp, use24HourTime } 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,16 +69,18 @@ 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,
|
||||||
config?.ui.time_format == "24hour"
|
is24Hour
|
||||||
? t("time.formattedTimestamp.24hour")
|
? t("time.formattedTimestamp.24hour")
|
||||||
: t("time.formattedTimestamp.12hour"),
|
: t("time.formattedTimestamp.12hour"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const formattedEnd = useFormattedTimestamp(
|
const formattedEnd = useFormattedTimestamp(
|
||||||
endTime,
|
endTime,
|
||||||
config?.ui.time_format == "24hour"
|
is24Hour
|
||||||
? t("time.formattedTimestamp.24hour")
|
? t("time.formattedTimestamp.24hour")
|
||||||
: t("time.formattedTimestamp.12hour"),
|
: t("time.formattedTimestamp.12hour"),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp, use24HourTime } 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,9 +769,10 @@ function ObjectDetailsTab({
|
|||||||
setShowNavigationButtons,
|
setShowNavigationButtons,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const is24Hour = use24HourTime(config);
|
||||||
const formattedDate = useFormattedTimestamp(
|
const formattedDate = useFormattedTimestamp(
|
||||||
search?.start_time ?? 0,
|
search?.start_time ?? 0,
|
||||||
config?.ui.time_format == "24hour"
|
is24Hour
|
||||||
? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
|
? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
})
|
})
|
||||||
|
|||||||
@ -7,6 +7,7 @@ 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";
|
||||||
@ -428,11 +429,12 @@ 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:
|
date_format: is24Hour
|
||||||
config.ui.time_format == "24hour"
|
|
||||||
? t("time.formattedTimestamp.24hour", {
|
? t("time.formattedTimestamp.24hour", {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
})
|
})
|
||||||
@ -448,8 +450,7 @@ 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:
|
date_format: is24Hour
|
||||||
config.ui.time_format == "24hour"
|
|
||||||
? t("time.formattedTimestamp.24hour", {
|
? t("time.formattedTimestamp.24hour", {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
})
|
})
|
||||||
@ -917,13 +918,14 @@ 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:
|
date_format: is24Hour
|
||||||
config.ui.time_format == "24hour"
|
|
||||||
? t("time.formattedTimestampHourMinuteSecond.24hour", {
|
? t("time.formattedTimestampHourMinuteSecond.24hour", {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
})
|
})
|
||||||
@ -934,7 +936,7 @@ function LifecycleIconRow({
|
|||||||
date_style: "medium",
|
date_style: "medium",
|
||||||
})
|
})
|
||||||
: "",
|
: "",
|
||||||
[config, item.timestamp, t],
|
[config, is24Hour, item.timestamp, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ratio = useMemo(
|
const ratio = useMemo(
|
||||||
|
|||||||
@ -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 } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp, use24HourTime } 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,9 +174,10 @@ export default function PreviewThumbnailPlayer({
|
|||||||
|
|
||||||
// date
|
// date
|
||||||
|
|
||||||
|
const is24Hour = use24HourTime(config);
|
||||||
const formattedDate = useFormattedTimestamp(
|
const formattedDate = useFormattedTimestamp(
|
||||||
review.start_time,
|
review.start_time,
|
||||||
config?.ui.time_format == "24hour"
|
is24Hour
|
||||||
? 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,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ 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";
|
||||||
@ -398,10 +399,10 @@ 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:
|
date_format: is24Hour
|
||||||
config.ui.time_format == "24hour"
|
|
||||||
? t("time.formattedTimestampHourMinuteSecond.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",
|
||||||
@ -787,11 +788,11 @@ 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:
|
date_format: is24Hour
|
||||||
config.ui.time_format == "24hour"
|
|
||||||
? t("time.formattedTimestampHourMinuteSecond.24hour", {
|
? t("time.formattedTimestampHourMinuteSecond.24hour", {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp, useTimeFormat } 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 = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
const timeFormat = useTimeFormat(config);
|
||||||
|
|
||||||
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 = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
const timeFormat = useTimeFormat(config);
|
||||||
const format = t(`time.formattedTimestampHourMinute.${timeFormat}`);
|
const format = t(`time.formattedTimestampHourMinute.${timeFormat}`);
|
||||||
|
|
||||||
const formattedTimestamp = useFormattedTimestamp(
|
const formattedTimestamp = useFormattedTimestamp(
|
||||||
|
|||||||
@ -11,6 +11,9 @@ 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: {
|
||||||
|
|||||||
27
web/src/hooks/use-config-messages.ts
Normal file
27
web/src/hooks/use-config-messages.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@ -84,6 +84,18 @@ 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
|
||||||
|
|||||||
@ -4,6 +4,7 @@ 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";
|
||||||
|
|
||||||
@ -168,7 +169,7 @@ function useDraggableElement({
|
|||||||
const { t } = useTranslation(["common"]);
|
const { t } = useTranslation(["common"]);
|
||||||
const locale = useDateLocale();
|
const locale = useDateLocale();
|
||||||
|
|
||||||
const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
const timeFormat = useTimeFormat(config);
|
||||||
const format = useMemo(() => {
|
const format = useMemo(() => {
|
||||||
const formatKey = `time.${
|
const formatKey = `time.${
|
||||||
segmentDuration < 60 && !dense
|
segmentDuration < 60 && !dense
|
||||||
|
|||||||
@ -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, useState } from "react";
|
import { useEffect, useMemo, useRef, 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,7 +49,17 @@ function System() {
|
|||||||
setPage,
|
setPage,
|
||||||
100,
|
100,
|
||||||
);
|
);
|
||||||
const [lastUpdated, setLastUpdated] = useState<number>(Date.now() / 1000);
|
const [lastUpdated, setLastUpdated] = useState<number>(
|
||||||
|
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) {
|
||||||
@ -116,24 +126,37 @@ function System() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{page == "general" && (
|
{visitedTabs.has("general") && (
|
||||||
|
<div className={page == "general" ? "contents" : "hidden"}>
|
||||||
<GeneralMetrics
|
<GeneralMetrics
|
||||||
lastUpdated={lastUpdated}
|
lastUpdated={lastUpdated}
|
||||||
setLastUpdated={setLastUpdated}
|
setLastUpdated={setLastUpdated}
|
||||||
|
isActive={page == "general"}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{page == "enrichments" && (
|
{metrics.includes("enrichments") && visitedTabs.has("enrichments") && (
|
||||||
|
<div className={page == "enrichments" ? "contents" : "hidden"}>
|
||||||
<EnrichmentMetrics
|
<EnrichmentMetrics
|
||||||
lastUpdated={lastUpdated}
|
lastUpdated={lastUpdated}
|
||||||
setLastUpdated={setLastUpdated}
|
setLastUpdated={setLastUpdated}
|
||||||
|
isActive={page == "enrichments"}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{page == "storage" && <StorageMetrics setLastUpdated={setLastUpdated} />}
|
{visitedTabs.has("storage") && (
|
||||||
{page == "cameras" && (
|
<div className={page == "storage" ? "contents" : "hidden"}>
|
||||||
|
<StorageMetrics setLastUpdated={setLastUpdated} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visitedTabs.has("cameras") && (
|
||||||
|
<div className={page == "cameras" ? "contents" : "hidden"}>
|
||||||
<CameraMetrics
|
<CameraMetrics
|
||||||
lastUpdated={lastUpdated}
|
lastUpdated={lastUpdated}
|
||||||
setLastUpdated={setLastUpdated}
|
setLastUpdated={setLastUpdated}
|
||||||
|
isActive={page == "cameras"}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -28,6 +28,9 @@ 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 = {
|
||||||
@ -42,6 +45,8 @@ 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 = {
|
||||||
@ -53,6 +58,8 @@ export type EmbeddingsStats = {
|
|||||||
|
|
||||||
export type ExtraProcessStats = {
|
export type ExtraProcessStats = {
|
||||||
pid: number;
|
pid: number;
|
||||||
|
cpu?: string;
|
||||||
|
mem?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GpuStats = {
|
export type GpuStats = {
|
||||||
@ -60,8 +67,10 @@ 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 = {
|
||||||
|
|||||||
@ -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 } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp, use24HourTime } 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,9 +165,10 @@ 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,
|
||||||
config?.ui.time_format == "24hour"
|
is24Hour
|
||||||
? t("time.formattedTimestampMonthDayHourMinute.24hour", {
|
? t("time.formattedTimestampMonthDayHourMinute.24hour", {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
})
|
})
|
||||||
|
|||||||
@ -43,8 +43,9 @@ 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 } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp, use24HourTime } 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";
|
||||||
@ -452,7 +453,6 @@ 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,7 +476,6 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -484,7 +483,6 @@ 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"]);
|
||||||
@ -527,15 +525,18 @@ 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,
|
||||||
timeFormat === "24hour"
|
is24Hour
|
||||||
? 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,
|
||||||
timeFormat === "24hour"
|
is24Hour
|
||||||
? t("time.formattedTimestamp.24hour", { ns: "common" })
|
? t("time.formattedTimestamp.24hour", { ns: "common" })
|
||||||
: t("time.formattedTimestamp.12hour", { ns: "common" }),
|
: t("time.formattedTimestamp.12hour", { ns: "common" }),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp, use24HourTime } 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,12 +94,13 @@ export default function MotionSearchView({
|
|||||||
]);
|
]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const is24Hour = use24HourTime(config);
|
||||||
const resultTimestampFormat = useMemo(
|
const resultTimestampFormat = useMemo(
|
||||||
() =>
|
() =>
|
||||||
config.ui?.time_format === "24hour"
|
is24Hour
|
||||||
? t("time.formattedTimestamp.24hour", { ns: "common" })
|
? t("time.formattedTimestamp.24hour", { ns: "common" })
|
||||||
: t("time.formattedTimestamp.12hour", { ns: "common" }),
|
: t("time.formattedTimestamp.12hour", { ns: "common" }),
|
||||||
[config.ui?.time_format, t],
|
[is24Hour, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
|
|||||||
@ -31,6 +31,7 @@ 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";
|
||||||
|
|
||||||
@ -77,6 +78,7 @@ 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[]>(
|
||||||
@ -191,10 +193,7 @@ 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:
|
time_range: is24Hour ? ["00:00-23:59"] : ["12:00AM-11:59PM"],
|
||||||
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"],
|
||||||
@ -209,6 +208,7 @@ export default function SearchView({
|
|||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
config,
|
config,
|
||||||
|
is24Hour,
|
||||||
allLabels,
|
allLabels,
|
||||||
allZones,
|
allZones,
|
||||||
allSubLabels,
|
allSubLabels,
|
||||||
|
|||||||
@ -39,6 +39,7 @@ 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";
|
||||||
@ -89,6 +90,7 @@ 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)
|
||||||
@ -581,8 +583,7 @@ export default function TriggerView({
|
|||||||
?.last_triggered,
|
?.last_triggered,
|
||||||
{
|
{
|
||||||
timezone: config.ui.timezone,
|
timezone: config.ui.timezone,
|
||||||
date_format:
|
date_format: is24Hour
|
||||||
config.ui.time_format == "24hour"
|
|
||||||
? t(
|
? t(
|
||||||
"time.formattedTimestamp2.24hour",
|
"time.formattedTimestamp2.24hour",
|
||||||
{
|
{
|
||||||
@ -742,8 +743,7 @@ export default function TriggerView({
|
|||||||
?.last_triggered,
|
?.last_triggered,
|
||||||
{
|
{
|
||||||
timezone: config.ui.timezone,
|
timezone: config.ui.timezone,
|
||||||
date_format:
|
date_format: is24Hour
|
||||||
config.ui.time_format == "24hour"
|
|
||||||
? t(
|
? t(
|
||||||
"time.formattedTimestamp2.24hour",
|
"time.formattedTimestamp2.24hour",
|
||||||
{
|
{
|
||||||
|
|||||||
@ -5,7 +5,14 @@ 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 { useCallback, useEffect, useMemo, useState } from "react";
|
import {
|
||||||
|
Fragment,
|
||||||
|
startTransition,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { MdInfo } from "react-icons/md";
|
import { MdInfo } from "react-icons/md";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@ -20,10 +27,12 @@ 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"]);
|
||||||
@ -39,11 +48,11 @@ export default function CameraMetrics({
|
|||||||
|
|
||||||
// stats
|
// stats
|
||||||
|
|
||||||
const { data: initialStats } = useSWR<FrigateStats[]>(
|
const { data: initialStats, mutate: refreshStats } = useSWR<FrigateStats[]>(
|
||||||
[
|
[
|
||||||
"stats/history",
|
"stats/history",
|
||||||
{
|
{
|
||||||
keys: "cpu_usages,cameras,camera_fps,detection_fps,skipped_fps,service",
|
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",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
@ -60,19 +69,38 @@ export default function CameraMetrics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (statsHistory.length == 0) {
|
if (statsHistory.length == 0) {
|
||||||
setStatsHistory(initialStats);
|
startTransition(() => setStatsHistory(initialStats));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updatedStats) {
|
if (!isActive || !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(Date.now() / 1000);
|
setLastUpdated(updatedStats.service.last_updated);
|
||||||
}
|
}
|
||||||
}, [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
|
||||||
|
|
||||||
@ -168,15 +196,15 @@ export default function CameraMetrics({
|
|||||||
|
|
||||||
series[key]["ffmpeg"].data.push({
|
series[key]["ffmpeg"].data.push({
|
||||||
x: statsIdx,
|
x: statsIdx,
|
||||||
y: stats.cpu_usages[camStats.ffmpeg_pid.toString()]?.cpu ?? 0.0,
|
y: camStats.ffmpeg_cpu ?? "0",
|
||||||
});
|
});
|
||||||
series[key]["capture"].data.push({
|
series[key]["capture"].data.push({
|
||||||
x: statsIdx,
|
x: statsIdx,
|
||||||
y: stats.cpu_usages[camStats.capture_pid?.toString()]?.cpu ?? 0,
|
y: camStats.capture_cpu ?? "0",
|
||||||
});
|
});
|
||||||
series[key]["detect"].data.push({
|
series[key]["detect"].data.push({
|
||||||
x: statsIdx,
|
x: statsIdx,
|
||||||
y: stats.cpu_usages[camStats.pid?.toString()]?.cpu,
|
y: camStats.detect_cpu ?? "0",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -261,6 +289,7 @@ export default function CameraMetrics({
|
|||||||
dataLabels={["camera", "detect", "skipped"]}
|
dataLabels={["camera", "detect", "skipped"]}
|
||||||
updateTimes={updateTimes}
|
updateTimes={updateTimes}
|
||||||
data={overallFpsSeries}
|
data={overallFpsSeries}
|
||||||
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -272,10 +301,9 @@ 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}
|
||||||
@ -345,6 +373,7 @@ export default function CameraMetrics({
|
|||||||
data={Object.values(
|
data={Object.values(
|
||||||
cameraCpuSeries[camera.name] || {},
|
cameraCpuSeries[camera.name] || {},
|
||||||
)}
|
)}
|
||||||
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -363,6 +392,7 @@ export default function CameraMetrics({
|
|||||||
data={Object.values(
|
data={Object.values(
|
||||||
cameraFpsSeries[camera.name] || {},
|
cameraFpsSeries[camera.name] || {},
|
||||||
)}
|
)}
|
||||||
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -370,7 +400,7 @@ export default function CameraMetrics({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateStats } from "@/types/stats";
|
import { FrigateStats } from "@/types/stats";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import {
|
||||||
|
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";
|
||||||
@ -12,16 +18,18 @@ 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 } = useSWR<FrigateStats[]>(
|
const { data: initialStats, mutate: refreshStats } = useSWR<FrigateStats[]>(
|
||||||
["stats/history", { keys: "embeddings,service" }],
|
["stats/history", { keys: "embeddings,service.last_updated" }],
|
||||||
{
|
{
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
},
|
},
|
||||||
@ -36,19 +44,38 @@ export default function EnrichmentMetrics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (statsHistory.length == 0) {
|
if (statsHistory.length == 0) {
|
||||||
setStatsHistory(initialStats);
|
startTransition(() => setStatsHistory(initialStats));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updatedStats) {
|
if (!isActive || !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(Date.now() / 1000);
|
setLastUpdated(updatedStats.service.last_updated);
|
||||||
}
|
}
|
||||||
}, [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")) {
|
||||||
@ -205,6 +232,7 @@ 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 && (
|
||||||
@ -215,6 +243,7 @@ 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>
|
||||||
|
|||||||
@ -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 { useEffect, useMemo, useState } from "react";
|
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||||
import { useFrigateStats } from "@/api/ws";
|
import { useFrigateStats } from "@/api/ws";
|
||||||
import {
|
import {
|
||||||
DetectorCpuThreshold,
|
DetectorCpuThreshold,
|
||||||
@ -26,10 +26,12 @@ 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"]);
|
||||||
@ -37,10 +39,12 @@ export default function GeneralMetrics({
|
|||||||
|
|
||||||
// stats
|
// stats
|
||||||
|
|
||||||
const { data: initialStats } = useSWR<FrigateStats[]>(
|
const { data: initialStats, mutate: refreshStats } = 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,
|
||||||
@ -56,19 +60,38 @@ export default function GeneralMetrics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (statsHistory.length == 0) {
|
if (statsHistory.length == 0) {
|
||||||
setStatsHistory(initialStats);
|
startTransition(() => setStatsHistory(initialStats));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updatedStats) {
|
if (!isActive || !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(Date.now() / 1000);
|
setLastUpdated(updatedStats.service.last_updated);
|
||||||
}
|
}
|
||||||
}, [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;
|
||||||
@ -76,7 +99,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-vaapi" || key == "intel-qsv") {
|
if (key == "amd-vaapi" || key == "intel-gpu") {
|
||||||
vaCount += 1;
|
vaCount += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,7 +204,7 @@ export default function GeneralMetrics({
|
|||||||
series[key] = { name: key, data: [] };
|
series[key] = { name: key, data: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = stats.cpu_usages[detStats.pid.toString()]?.cpu;
|
const data = detStats.cpu;
|
||||||
|
|
||||||
if (data != undefined) {
|
if (data != undefined) {
|
||||||
series[key].data.push({
|
series[key].data.push({
|
||||||
@ -213,10 +236,12 @@ 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);
|
||||||
@ -265,7 +290,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].includes("intel")
|
Object.keys(statsHistory?.at(0)?.gpu_usages ?? {})[0] === "intel-gpu"
|
||||||
) {
|
) {
|
||||||
// intel gpu stats do not support memory
|
// intel gpu stats do not support memory
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -334,6 +359,43 @@ 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 [];
|
||||||
@ -409,9 +471,7 @@ export default function GeneralMetrics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gpuKeys = Object.keys(statsHistory[0]?.gpu_usages ?? {});
|
const gpuKeys = Object.keys(statsHistory[0]?.gpu_usages ?? {});
|
||||||
const hasIntelGpu = gpuKeys.some(
|
const hasIntelGpu = gpuKeys.some((key) => key === "intel-gpu");
|
||||||
(key) => key === "intel-vaapi" || key === "intel-qsv",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasIntelGpu) {
|
if (!hasIntelGpu) {
|
||||||
return false;
|
return false;
|
||||||
@ -427,7 +487,7 @@ export default function GeneralMetrics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(stats.gpu_usages || {}).forEach(([key, gpuStats]) => {
|
Object.entries(stats.gpu_usages || {}).forEach(([key, gpuStats]) => {
|
||||||
if (key === "intel-vaapi" || key === "intel-qsv") {
|
if (key === "intel-gpu") {
|
||||||
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));
|
||||||
@ -546,7 +606,6 @@ export default function GeneralMetrics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(stats.processes).forEach(([key, procStats]) => {
|
Object.entries(stats.processes).forEach(([key, procStats]) => {
|
||||||
if (procStats.pid.toString() in stats.cpu_usages) {
|
|
||||||
if (!(key in series)) {
|
if (!(key in series)) {
|
||||||
series[key] = {
|
series[key] = {
|
||||||
name: t(`general.otherProcesses.series.${key}`),
|
name: t(`general.otherProcesses.series.${key}`),
|
||||||
@ -554,15 +613,12 @@ export default function GeneralMetrics({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = stats.cpu_usages[procStats.pid.toString()]?.cpu;
|
if (procStats.cpu != undefined) {
|
||||||
|
|
||||||
if (data != undefined) {
|
|
||||||
series[key].data.push({
|
series[key].data.push({
|
||||||
x: statsIdx + 1,
|
x: statsIdx + 1,
|
||||||
y: data,
|
y: procStats.cpu,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return Object.keys(series).length > 0 ? Object.values(series) : [];
|
return Object.keys(series).length > 0 ? Object.values(series) : [];
|
||||||
@ -583,7 +639,6 @@ export default function GeneralMetrics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(stats.processes).forEach(([key, procStats]) => {
|
Object.entries(stats.processes).forEach(([key, procStats]) => {
|
||||||
if (procStats.pid.toString() in stats.cpu_usages) {
|
|
||||||
if (!(key in series)) {
|
if (!(key in series)) {
|
||||||
series[key] = {
|
series[key] = {
|
||||||
name: t(`general.otherProcesses.series.${key}`),
|
name: t(`general.otherProcesses.series.${key}`),
|
||||||
@ -591,15 +646,12 @@ export default function GeneralMetrics({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = stats.cpu_usages[procStats.pid.toString()]?.mem;
|
if (procStats.mem) {
|
||||||
|
|
||||||
if (data) {
|
|
||||||
series[key].data.push({
|
series[key].data.push({
|
||||||
x: statsIdx + 1,
|
x: statsIdx + 1,
|
||||||
y: data,
|
y: procStats.mem,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return Object.values(series);
|
return Object.values(series);
|
||||||
@ -635,6 +687,7 @@ export default function GeneralMetrics({
|
|||||||
threshold={InferenceThreshold}
|
threshold={InferenceThreshold}
|
||||||
updateTimes={updateTimes}
|
updateTimes={updateTimes}
|
||||||
data={[series]}
|
data={[series]}
|
||||||
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -657,6 +710,7 @@ export default function GeneralMetrics({
|
|||||||
threshold={DetectorTempThreshold}
|
threshold={DetectorTempThreshold}
|
||||||
updateTimes={updateTimes}
|
updateTimes={updateTimes}
|
||||||
data={[series]}
|
data={[series]}
|
||||||
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -695,6 +749,7 @@ export default function GeneralMetrics({
|
|||||||
threshold={DetectorCpuThreshold}
|
threshold={DetectorCpuThreshold}
|
||||||
updateTimes={updateTimes}
|
updateTimes={updateTimes}
|
||||||
data={[series]}
|
data={[series]}
|
||||||
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -713,6 +768,7 @@ export default function GeneralMetrics({
|
|||||||
threshold={DetectorMemThreshold}
|
threshold={DetectorMemThreshold}
|
||||||
updateTimes={updateTimes}
|
updateTimes={updateTimes}
|
||||||
data={[series]}
|
data={[series]}
|
||||||
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -744,8 +800,9 @@ 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 && "xl:grid-cols-4",
|
(gpuEncSeries?.length || gpuComputeSeries?.length) &&
|
||||||
gpuEncSeries?.length &&
|
"xl:grid-cols-4",
|
||||||
|
(gpuEncSeries?.length || gpuComputeSeries?.length) &&
|
||||||
gpuTempSeries?.length &&
|
gpuTempSeries?.length &&
|
||||||
"3xl:grid-cols-5",
|
"3xl:grid-cols-5",
|
||||||
)}
|
)}
|
||||||
@ -804,6 +861,7 @@ export default function GeneralMetrics({
|
|||||||
threshold={GPUUsageThreshold}
|
threshold={GPUUsageThreshold}
|
||||||
updateTimes={updateTimes}
|
updateTimes={updateTimes}
|
||||||
data={[series]}
|
data={[series]}
|
||||||
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -826,6 +884,7 @@ export default function GeneralMetrics({
|
|||||||
threshold={GPUMemThreshold}
|
threshold={GPUMemThreshold}
|
||||||
updateTimes={updateTimes}
|
updateTimes={updateTimes}
|
||||||
data={[series]}
|
data={[series]}
|
||||||
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -850,6 +909,32 @@ 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>
|
||||||
@ -874,6 +959,7 @@ export default function GeneralMetrics({
|
|||||||
threshold={GPUMemThreshold}
|
threshold={GPUMemThreshold}
|
||||||
updateTimes={updateTimes}
|
updateTimes={updateTimes}
|
||||||
data={[series]}
|
data={[series]}
|
||||||
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -898,6 +984,7 @@ export default function GeneralMetrics({
|
|||||||
threshold={DetectorTempThreshold}
|
threshold={DetectorTempThreshold}
|
||||||
updateTimes={updateTimes}
|
updateTimes={updateTimes}
|
||||||
data={[series]}
|
data={[series]}
|
||||||
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -923,6 +1010,7 @@ export default function GeneralMetrics({
|
|||||||
threshold={GPUUsageThreshold}
|
threshold={GPUUsageThreshold}
|
||||||
updateTimes={updateTimes}
|
updateTimes={updateTimes}
|
||||||
data={[series]}
|
data={[series]}
|
||||||
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -945,6 +1033,7 @@ export default function GeneralMetrics({
|
|||||||
threshold={DetectorTempThreshold}
|
threshold={DetectorTempThreshold}
|
||||||
updateTimes={updateTimes}
|
updateTimes={updateTimes}
|
||||||
data={[series]}
|
data={[series]}
|
||||||
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -978,6 +1067,7 @@ export default function GeneralMetrics({
|
|||||||
threshold={DetectorCpuThreshold}
|
threshold={DetectorCpuThreshold}
|
||||||
updateTimes={updateTimes}
|
updateTimes={updateTimes}
|
||||||
data={[series]}
|
data={[series]}
|
||||||
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -997,6 +1087,7 @@ export default function GeneralMetrics({
|
|||||||
threshold={DetectorMemThreshold}
|
threshold={DetectorMemThreshold}
|
||||||
updateTimes={updateTimes}
|
updateTimes={updateTimes}
|
||||||
data={[series]}
|
data={[series]}
|
||||||
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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 { useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@ -10,7 +10,11 @@ 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 { useFormattedTimestamp, useTimezone } from "@/hooks/use-date-utils";
|
import {
|
||||||
|
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";
|
||||||
@ -56,9 +60,14 @@ 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, setLastUpdated]);
|
}, [cameraStorage, stats]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (totalStorage) {
|
||||||
|
setLastUpdated(Math.floor(Date.now() / 1000));
|
||||||
|
}
|
||||||
|
}, [totalStorage, setLastUpdated]);
|
||||||
|
|
||||||
// recordings summary
|
// recordings summary
|
||||||
|
|
||||||
@ -76,7 +85,7 @@ export default function StorageMetrics({
|
|||||||
: null;
|
: null;
|
||||||
}, [recordingsSummary, timezone]);
|
}, [recordingsSummary, timezone]);
|
||||||
|
|
||||||
const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour";
|
const timeFormat = useTimeFormat(config);
|
||||||
const format = useMemo(() => {
|
const format = useMemo(() => {
|
||||||
return t(`time.formattedTimestampMonthDayYear.${timeFormat}`, {
|
return t(`time.formattedTimestampMonthDayYear.${timeFormat}`, {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user