mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-24 18:26:51 +03:00
Miscellaneous fixes (#24402)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* check for a valid frame before using its shape With the camera offline, no preview frame, and `camera-error.jpg` missing, `latest_frame` read `frame.shape` before its `frame is None` check, so it raised `AttributeError` and answered 500 with a traceback instead of the intended "Unable to get valid frame". The check now runs first. * fix the has_clip self-heal for events with no recordings `vod_event` looked for a `(body, 404)` tuple, but `vod_ts` returns a `JSONResponse`, so the check never matched and an old event whose recordings are gone kept offering a clip that can't play. It now checks the response status code. * return 403 for a snapshot or thumbnail on another camera The broad `except Exception` handlers in `event_snapshot` and `event_thumbnail` caught the `HTTPException` from `require_camera_access`, so a restricted user asking for another camera's snapshot got a 404 instead of a 403, and for an object still being tracked the snapshot was rendered before the check ran. Both endpoints now look up the event and check access in their own block, the way the other endpoints do, so a denial propagates. * find DST transitions to the second `get_dst_transitions` probed the offset once every 24 hours from the start time and reported a change at the first probe after it, up to a day late, so events, review items and recordings near a transition were grouped into days with the old offset. A transition after the last daily probe wasn't found at all. The end of the range is probed too now, and a probe that sees the offset change bisects the interval to the second of the transition. * don't run page shortcuts for keys a dialog already handled Radix dismisses a dialog on Escape from a capture-phase keydown listener and calls `preventDefault()` without stopping propagation, so `useKeyboardListener` still ran the page's Escape shortcut: cancelling the delete dialog in the face library or a classification model also cleared the whole selection. Keys another shortcut hook handled still get through, since their listener order changes with every render. * fix train image filtering for a class with a dash The backend writes a class with a `-` as `_` in train file names, since it splits those names on `-`, while a dataset folder keeps the dash. Filtering the Train grid by `half-open` compared it with `half_open` and hid every attempt. Both sides are normalized the same way now. * don't edit a chat message while a reply streams The edit button stayed active while a reply streamed. `submitConversation` returns early while loading, but the message bubble still closed its editor, so the edit was silently lost. The edit button is hidden while a reply streams, and an editor that's already open keeps its draft with send disabled until the reply ends. * fix restart failing under non-root restart_frigate() called psutil.Process(1).terminate() to signal s6-svscan, but s6-svscan runs as root while frigate runs as uid 1000, so the call raised AccessDenied. That exception escaped every caller: the UI restart button dropped its websocket client, MQTT restart and Save & Restart just logged and did nothing, and the watchdog crashed its own monitoring thread on a dead detector. This catches AccessDenied and falls through to the existing SIGINT branch, which exits the process for s6 to restart it. * show runtime overrides in the settings form The settings form read a camera section's saved config value, but its dependent warnings (audio transcription requiring audio detection, snapshots requiring detect, etc.) read the live config instead. A runtime toggle from the live view, MQTT, or an active profile can turn a section off without touching yaml, and that override persists across restarts, so the Enable switch showed on while the warning said the feature wasn't enabled. This adds an "Overridden (Live)" badge to any field whose live value differs from what's saved, and swaps the affected warnings to runtime-specific wording when a runtime override is the actual cause instead of the config. * fix mobile overflowing icons in system due to new health pane * fix genai settings keeping a stale model and dropping roles after save Switching a GenAI entry's provider left the previous provider's model selected, so saving wrote a model the new provider doesn't serve. llama.cpp can't find that model in `/v1/models`, so the backend reported every capability as false for the entry, and once the save refetched `genai/models` the roles widget stripped `transcribe` from the form on its own. The section showed unsaved changes right after saving, and saving again would have dropped the role. Switching provider now clears the model, and the roles widget only strips a role for a model or provider picked in the form, since the entry-level capability flags only describe the saved model. A selected role stays visible when the provider can't confirm it, so it can still be switched off. The llama.cpp model list also no longer repeats a model whose alias matches its id, which is what `--alias` produces. * close onvif sessions on shutdown `OnvifController.close()` only stopped its event loop, so the aiohttp sessions each `ONVIFCamera` holds and the `_poll_config_updates` task were left to be garbage collected during interpreter shutdown, when their warnings can no longer be logged. Every restart ended with a run of `Unclosed client session` and `Task was destroyed but it is pending!` logging errors, which only became visible once restart started exiting the process itself under non-root. `close()` now closes each camera's client and cancels the tasks on the loop before stopping it. * fixes * fixes
This commit is contained in:
+82
-61
@@ -258,15 +258,15 @@ async def latest_frame(
|
||||
|
||||
frame = request.app.camera_error_image
|
||||
|
||||
height = int(params.height or str(frame.shape[0]))
|
||||
width = int(height * frame.shape[1] / frame.shape[0])
|
||||
|
||||
if frame is None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Unable to get valid frame"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
height = int(params.height or str(frame.shape[0]))
|
||||
width = int(height * frame.shape[1] / frame.shape[0])
|
||||
|
||||
if height < 1 or width < 1:
|
||||
return JSONResponse(
|
||||
content="Invalid height / width requested :: {} / {}".format(
|
||||
@@ -886,9 +886,8 @@ async def vod_event(
|
||||
# If the recordings are not found and the event started more than 5 minutes ago, set has_clip to false
|
||||
if (
|
||||
event.start_time < datetime.now().timestamp() - 300
|
||||
and type(vod_response) is tuple
|
||||
and len(vod_response) == 2
|
||||
and vod_response[1] == 404
|
||||
and isinstance(vod_response, JSONResponse)
|
||||
and vod_response.status_code == 404
|
||||
):
|
||||
Event.update(has_clip=False).where(Event.id == event_id).execute()
|
||||
|
||||
@@ -956,64 +955,80 @@ async def event_snapshot(
|
||||
event_complete = False
|
||||
jpg_bytes = None
|
||||
frame_time = 0
|
||||
|
||||
try:
|
||||
event = Event.get(Event.id == event_id, Event.end_time != None)
|
||||
event_complete = True
|
||||
await require_camera_access(event.camera, request=request)
|
||||
except DoesNotExist:
|
||||
event = None
|
||||
|
||||
if event is not None:
|
||||
event_complete = True
|
||||
|
||||
if not event.has_snapshot:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Snapshot not available"},
|
||||
status_code=404,
|
||||
)
|
||||
snapshot_settings = _resolve_snapshot_settings(
|
||||
request.app.frigate_config.cameras[event.camera].snapshots, params
|
||||
)
|
||||
jpg_bytes, frame_time = get_event_snapshot_bytes(
|
||||
event,
|
||||
ext="jpg",
|
||||
timestamp=snapshot_settings["timestamp"],
|
||||
bounding_box=snapshot_settings["bounding_box"],
|
||||
crop=snapshot_settings["crop"],
|
||||
height=snapshot_settings["height"],
|
||||
quality=snapshot_settings["quality"],
|
||||
timestamp_style=request.app.frigate_config.cameras[
|
||||
event.camera
|
||||
].timestamp_style,
|
||||
colormap=request.app.frigate_config.model_for_camera(event.camera).colormap,
|
||||
)
|
||||
except DoesNotExist:
|
||||
# see if the object is currently being tracked
|
||||
|
||||
try:
|
||||
camera_states: list[CameraState] = (
|
||||
request.app.detected_frames_processor.get_camera_states()
|
||||
snapshot_settings = _resolve_snapshot_settings(
|
||||
request.app.frigate_config.cameras[event.camera].snapshots, params
|
||||
)
|
||||
jpg_bytes, frame_time = get_event_snapshot_bytes(
|
||||
event,
|
||||
ext="jpg",
|
||||
timestamp=snapshot_settings["timestamp"],
|
||||
bounding_box=snapshot_settings["bounding_box"],
|
||||
crop=snapshot_settings["crop"],
|
||||
height=snapshot_settings["height"],
|
||||
quality=snapshot_settings["quality"],
|
||||
timestamp_style=request.app.frigate_config.cameras[
|
||||
event.camera
|
||||
].timestamp_style,
|
||||
colormap=request.app.frigate_config.model_for_camera(
|
||||
event.camera
|
||||
).colormap,
|
||||
)
|
||||
for camera_state in camera_states:
|
||||
if event_id in camera_state.tracked_objects:
|
||||
tracked_obj = camera_state.tracked_objects.get(event_id)
|
||||
if tracked_obj is not None:
|
||||
snapshot_settings = _resolve_snapshot_settings(
|
||||
camera_state.camera_config.snapshots, params
|
||||
)
|
||||
jpg_bytes, frame_time = tracked_obj.get_img_bytes(
|
||||
ext="jpg",
|
||||
timestamp=snapshot_settings["timestamp"],
|
||||
bounding_box=snapshot_settings["bounding_box"],
|
||||
crop=snapshot_settings["crop"],
|
||||
height=snapshot_settings["height"],
|
||||
quality=snapshot_settings["quality"],
|
||||
)
|
||||
await require_camera_access(camera_state.name, request=request)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Ongoing event not found"},
|
||||
content={"success": False, "message": "Unknown error occurred"},
|
||||
status_code=404,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Unknown error occurred"},
|
||||
status_code=404,
|
||||
else:
|
||||
# see if the object is currently being tracked
|
||||
camera_states: list[CameraState] = (
|
||||
request.app.detected_frames_processor.get_camera_states()
|
||||
)
|
||||
|
||||
for camera_state in camera_states:
|
||||
tracked_obj = camera_state.tracked_objects.get(event_id)
|
||||
|
||||
if tracked_obj is None:
|
||||
continue
|
||||
|
||||
await require_camera_access(camera_state.name, request=request)
|
||||
|
||||
try:
|
||||
snapshot_settings = _resolve_snapshot_settings(
|
||||
camera_state.camera_config.snapshots, params
|
||||
)
|
||||
jpg_bytes, frame_time = tracked_obj.get_img_bytes(
|
||||
ext="jpg",
|
||||
timestamp=snapshot_settings["timestamp"],
|
||||
bounding_box=snapshot_settings["bounding_box"],
|
||||
crop=snapshot_settings["crop"],
|
||||
height=snapshot_settings["height"],
|
||||
quality=snapshot_settings["quality"],
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Ongoing event not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
if jpg_bytes is None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Live frame not available"},
|
||||
@@ -1062,19 +1077,25 @@ async def event_thumbnail(
|
||||
|
||||
if not thumbnail_bytes:
|
||||
# see if the object is currently being tracked
|
||||
try:
|
||||
camera_states = request.app.detected_frames_processor.get_camera_states()
|
||||
for camera_state in camera_states:
|
||||
if event_id in camera_state.tracked_objects:
|
||||
tracked_obj = camera_state.tracked_objects.get(event_id)
|
||||
if tracked_obj is not None:
|
||||
await require_camera_access(camera_state.name, request=request)
|
||||
thumbnail_bytes = tracked_obj.get_thumbnail(extension.value)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Event not found"},
|
||||
status_code=404,
|
||||
)
|
||||
camera_states = request.app.detected_frames_processor.get_camera_states()
|
||||
|
||||
for camera_state in camera_states:
|
||||
tracked_obj = camera_state.tracked_objects.get(event_id)
|
||||
|
||||
if tracked_obj is None:
|
||||
continue
|
||||
|
||||
await require_camera_access(camera_state.name, request=request)
|
||||
|
||||
try:
|
||||
thumbnail_bytes = tracked_obj.get_thumbnail(extension.value)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Event not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
if not thumbnail_bytes:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -560,13 +560,12 @@ class LlamaCppClient(GenAIClient):
|
||||
|
||||
def list_models(self) -> list[str]:
|
||||
"""Return available model IDs from the llama.cpp server."""
|
||||
models = []
|
||||
models: set[str] = set()
|
||||
|
||||
# llama-server lists the id among the aliases when --alias is set
|
||||
for m in self._fetch_models_data():
|
||||
models.append(m.get("id", "unknown"))
|
||||
|
||||
for alias in m.get("aliases", []):
|
||||
models.append(alias)
|
||||
models.add(m.get("id", "unknown"))
|
||||
models.update(m.get("aliases", []))
|
||||
|
||||
return sorted(models)
|
||||
|
||||
|
||||
@@ -1120,6 +1120,18 @@ class OnvifController:
|
||||
f"Camera {camera_name} is still in ONVIF 'MOVING' status."
|
||||
)
|
||||
|
||||
async def _shutdown(self) -> None:
|
||||
"""Close the camera sessions and cancel the tasks running on the loop."""
|
||||
for cam_name in list(self.cams):
|
||||
await self._close_camera(cam_name)
|
||||
|
||||
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
||||
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Gracefully shut down the ONVIF controller."""
|
||||
if not hasattr(self, "loop") or self.loop.is_closed():
|
||||
@@ -1127,6 +1139,16 @@ class OnvifController:
|
||||
return
|
||||
|
||||
logger.info("Exiting ONVIF controller...")
|
||||
|
||||
# anything left open here is garbage collected during interpreter
|
||||
# shutdown, where its warnings can no longer be logged cleanly
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(self._shutdown(), self.loop).result(
|
||||
timeout=5
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.debug("Timed out closing ONVIF sessions")
|
||||
|
||||
self.config_subscriber.stop()
|
||||
|
||||
def stop_and_cleanup():
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests for get_dst_transitions."""
|
||||
|
||||
import datetime
|
||||
import unittest
|
||||
|
||||
from frigate.util.time import get_dst_transitions
|
||||
|
||||
|
||||
class TestDstTransitions(unittest.TestCase):
|
||||
def test_dst_transition_splits_periods_at_the_transition(self):
|
||||
start = datetime.datetime(2026, 3, 7, 12, tzinfo=datetime.UTC).timestamp()
|
||||
end = start + 2 * 86400
|
||||
spring = datetime.datetime(2026, 3, 8, 7, tzinfo=datetime.UTC).timestamp()
|
||||
self.assertEqual(
|
||||
get_dst_transitions("America/New_York", start, end),
|
||||
[(start, spring, -18000), (spring, end, -14400)],
|
||||
)
|
||||
|
||||
def test_dst_transition_is_not_reported_a_day_late(self):
|
||||
# local midnight on the day of the change used to report the
|
||||
# transition a full day after it actually happened
|
||||
start = datetime.datetime(2024, 3, 10, 5, tzinfo=datetime.UTC).timestamp()
|
||||
end = start + 3 * 86400
|
||||
spring = datetime.datetime(2024, 3, 10, 7, tzinfo=datetime.UTC).timestamp()
|
||||
self.assertEqual(
|
||||
get_dst_transitions("America/New_York", start, end),
|
||||
[(start, spring, -18000), (spring, end, -14400)],
|
||||
)
|
||||
|
||||
def test_dst_transition_after_the_last_daily_probe_is_found(self):
|
||||
start = datetime.datetime(2024, 11, 2, 12, tzinfo=datetime.UTC).timestamp()
|
||||
end = datetime.datetime(2024, 11, 3, 10, tzinfo=datetime.UTC).timestamp()
|
||||
fall = datetime.datetime(2024, 11, 3, 6, tzinfo=datetime.UTC).timestamp()
|
||||
self.assertEqual(
|
||||
get_dst_transitions("America/New_York", start, end),
|
||||
[(start, fall, -14400), (fall, end, -18000)],
|
||||
)
|
||||
|
||||
def test_no_transition_returns_a_single_period(self):
|
||||
start = datetime.datetime(2026, 6, 1, tzinfo=datetime.UTC).timestamp()
|
||||
end = start + 5 * 86400
|
||||
self.assertEqual(
|
||||
get_dst_transitions("America/New_York", start, end),
|
||||
[(start, end, -14400)],
|
||||
)
|
||||
|
||||
def test_invalid_zone_retains_utc_fallback(self):
|
||||
self.assertEqual(
|
||||
get_dst_transitions("Invalid/Timezone", 100, 200), [(100, 200, 0)]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -580,6 +580,15 @@ class TestLlamaCppProvider(unittest.TestCase):
|
||||
client = self._validated_client(4096, {"context_size": 32768})
|
||||
self.assertEqual(client.get_context_size(), 32768)
|
||||
|
||||
def test_list_models_dedupes_alias_matching_id(self):
|
||||
client = self._client()
|
||||
models_data = [
|
||||
{"id": "qwen3-asr", "aliases": ["qwen3-asr"]},
|
||||
{"id": "gemma", "aliases": ["gemma", "g4"]},
|
||||
]
|
||||
with patch.object(client, "_fetch_models_data", return_value=models_data):
|
||||
self.assertEqual(client.list_models(), ["g4", "gemma", "qwen3-asr"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# transcribe role
|
||||
|
||||
@@ -15,6 +15,8 @@ Also covers the inverse direction: the ptz movement timestamps must not be writt
|
||||
for a camera that has autotracking off, because nothing clears them back out.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
@@ -234,5 +236,40 @@ class TestManualRelativeMoveMetrics(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestOnvifClose(unittest.TestCase):
|
||||
"""close() must release everything on the loop, since whatever it leaves is
|
||||
garbage collected during interpreter shutdown, where the resulting warnings
|
||||
fail to log and fill the shutdown output with logging errors."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.controller = _make_controller(autotracking_enabled=False)
|
||||
self.onvif = self.controller.cams[CAMERA]["onvif"]
|
||||
self.onvif.close = AsyncMock()
|
||||
self.controller.config_subscriber = MagicMock()
|
||||
self.controller.loop = asyncio.new_event_loop()
|
||||
self.controller.loop_thread = threading.Thread(
|
||||
target=self.controller._run_event_loop, daemon=True
|
||||
)
|
||||
self.controller.loop_thread.start()
|
||||
self.addCleanup(self.controller.loop.close)
|
||||
|
||||
def test_close_closes_camera_sessions(self) -> None:
|
||||
self.controller.close()
|
||||
|
||||
self.onvif.close.assert_awaited_once()
|
||||
|
||||
def test_close_cancels_tasks_left_on_the_loop(self) -> None:
|
||||
async def forever() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
poll = asyncio.run_coroutine_threadsafe(forever(), self.controller.loop)
|
||||
|
||||
self.controller.close()
|
||||
|
||||
self.assertTrue(poll.cancelled())
|
||||
self.assertFalse(self.controller.loop_thread.is_alive())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Tests for restarting frigate under s6."""
|
||||
|
||||
import signal
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import psutil
|
||||
|
||||
from frigate.util.services import restart_frigate
|
||||
|
||||
|
||||
class TestRestartFrigate(unittest.TestCase):
|
||||
def _s6_process(self) -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.name.return_value = "s6-svscan"
|
||||
return proc
|
||||
|
||||
@patch("frigate.util.services.os.kill")
|
||||
@patch("frigate.util.services.psutil.Process")
|
||||
def test_terminates_s6_when_permitted(self, mock_process, mock_kill):
|
||||
proc = self._s6_process()
|
||||
mock_process.return_value = proc
|
||||
|
||||
restart_frigate()
|
||||
|
||||
proc.terminate.assert_called_once()
|
||||
mock_kill.assert_not_called()
|
||||
|
||||
@patch("frigate.util.services.os.getpid", return_value=99)
|
||||
@patch("frigate.util.services.os.kill")
|
||||
@patch("frigate.util.services.psutil.Process")
|
||||
def test_exits_self_when_s6_signal_is_denied(
|
||||
self, mock_process, mock_kill, _mock_getpid
|
||||
):
|
||||
"""Running unprivileged, frigate cannot signal root's s6-svscan."""
|
||||
proc = self._s6_process()
|
||||
proc.terminate.side_effect = psutil.AccessDenied(pid=1, name="s6-svscan")
|
||||
mock_process.return_value = proc
|
||||
|
||||
restart_frigate()
|
||||
|
||||
mock_kill.assert_called_once_with(99, signal.SIGINT)
|
||||
|
||||
@patch("frigate.util.services.os.getpid", return_value=99)
|
||||
@patch("frigate.util.services.os.kill")
|
||||
@patch("frigate.util.services.psutil.Process")
|
||||
def test_exits_self_without_s6(self, mock_process, mock_kill, _mock_getpid):
|
||||
proc = MagicMock()
|
||||
proc.name.return_value = "init"
|
||||
mock_process.return_value = proc
|
||||
|
||||
restart_frigate()
|
||||
|
||||
proc.terminate.assert_not_called()
|
||||
mock_kill.assert_called_once_with(99, signal.SIGINT)
|
||||
@@ -35,12 +35,19 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def restart_frigate():
|
||||
proc = psutil.Process(1)
|
||||
|
||||
# if this is running via s6, sigterm pid 1
|
||||
if proc.name() == "s6-svscan":
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.terminate()
|
||||
return
|
||||
except psutil.AccessDenied:
|
||||
# frigate runs unprivileged, so it cannot signal root's s6-svscan.
|
||||
# exiting this process instead runs frigate/finish, which halts s6
|
||||
logger.debug("Not permitted to signal s6-svscan, exiting instead")
|
||||
|
||||
# otherwise, just try and exit frigate
|
||||
else:
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
|
||||
|
||||
def print_stack(sig, frame):
|
||||
|
||||
+40
-19
@@ -2,6 +2,7 @@
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import math
|
||||
from zoneinfo import ZoneInfoNotFoundError
|
||||
|
||||
import pytz
|
||||
@@ -43,9 +44,33 @@ def is_current_hour(timestamp: int) -> bool:
|
||||
return timestamp < start_of_next_hour
|
||||
|
||||
|
||||
def _utc_offset(tz: datetime.tzinfo, timestamp: float) -> float:
|
||||
dt = datetime.datetime.fromtimestamp(timestamp, tz=datetime.UTC)
|
||||
return dt.astimezone(tz).utcoffset().total_seconds()
|
||||
|
||||
|
||||
def _find_transition(
|
||||
tz: datetime.tzinfo, lo: float, hi: float, lo_offset: float
|
||||
) -> float:
|
||||
"""Bisect (lo, hi] to the second where the UTC offset first differs from lo_offset."""
|
||||
# whole seconds, so the midpoint always advances (a fractional bound can
|
||||
# otherwise leave the midpoint sitting on lo) and lands on the transition
|
||||
low = math.floor(lo)
|
||||
high = math.ceil(hi)
|
||||
|
||||
while high - low > 1:
|
||||
mid = (low + high) // 2
|
||||
if _utc_offset(tz, mid) == lo_offset:
|
||||
low = mid
|
||||
else:
|
||||
high = mid
|
||||
|
||||
return float(high)
|
||||
|
||||
|
||||
def get_dst_transitions(
|
||||
tz_name: str, start_time: float, end_time: float
|
||||
) -> list[tuple[float, float]]:
|
||||
) -> list[tuple[float, float, float]]:
|
||||
"""
|
||||
Find DST transition points and return time periods with consistent offsets.
|
||||
|
||||
@@ -66,28 +91,24 @@ def get_dst_transitions(
|
||||
|
||||
periods = []
|
||||
current = start_time
|
||||
|
||||
# Get initial offset
|
||||
dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC)
|
||||
local_dt = dt.astimezone(tz)
|
||||
prev_offset = local_dt.utcoffset().total_seconds()
|
||||
period_start = start_time
|
||||
prev_offset = _utc_offset(tz, current)
|
||||
|
||||
# Check each day for offset changes
|
||||
while current <= end_time:
|
||||
dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC)
|
||||
local_dt = dt.astimezone(tz)
|
||||
current_offset = local_dt.utcoffset().total_seconds()
|
||||
# Probe at most a day ahead, capped at end_time so a transition after the
|
||||
# last full day is still seen instead of silently kept in the last period.
|
||||
while current < end_time:
|
||||
next_probe = min(current + 86400, end_time)
|
||||
next_offset = _utc_offset(tz, next_probe)
|
||||
|
||||
if current_offset != prev_offset:
|
||||
# Found a transition - close previous period
|
||||
periods.append((period_start, current, prev_offset))
|
||||
period_start = current
|
||||
prev_offset = current_offset
|
||||
if next_offset != prev_offset:
|
||||
transition = _find_transition(tz, current, next_probe, prev_offset)
|
||||
periods.append((period_start, transition, prev_offset))
|
||||
period_start = transition
|
||||
prev_offset = _utc_offset(tz, transition)
|
||||
current = transition
|
||||
else:
|
||||
current = next_probe
|
||||
|
||||
current += 86400 # Check daily
|
||||
|
||||
# Add final period
|
||||
periods.append((period_start, end_time, prev_offset))
|
||||
|
||||
return periods
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Generative AI provider settings tests -- MEDIUM tier.
|
||||
*
|
||||
* A model name belongs to its provider, so switching provider clears the model
|
||||
* field. The roles widget strips a role only for a model or provider picked in
|
||||
* the form, never when capability data arrives for the saved entry, which would
|
||||
* dirty the section on load and silently drop the role on the next save.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { configFactory } from "../../fixtures/mock-data/config";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_SCHEMA = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
|
||||
const ENTRY = "audio";
|
||||
const SETTINGS_URL = "/settings?page=integrationGenerativeAi";
|
||||
const UNSAVED = "You have unsaved changes";
|
||||
const MODEL_PLACEHOLDER = "Select or enter a model…";
|
||||
|
||||
type Entry = {
|
||||
provider: string;
|
||||
model: string;
|
||||
base_url?: string;
|
||||
roles: string[];
|
||||
};
|
||||
|
||||
type ProviderInfo = {
|
||||
models: string[];
|
||||
supports_transcription: boolean;
|
||||
model_capabilities?: Record<string, { supports_transcription?: boolean }>;
|
||||
};
|
||||
|
||||
async function installRoutes(page: Page, entry: Entry, info: ProviderInfo) {
|
||||
const config = configFactory({ genai: { [ENTRY]: entry } });
|
||||
|
||||
await page.route("**/api/config/schema.json", (route) =>
|
||||
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||
);
|
||||
await page.route("**/api/config", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({ json: config });
|
||||
}
|
||||
return route.fulfill({ json: { success: true } });
|
||||
});
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({ json: { genai: { [ENTRY]: entry } } }),
|
||||
);
|
||||
await page.route("**/api/genai/models", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
[ENTRY]: {
|
||||
roles: entry.roles,
|
||||
supports_toggleable_thinking: false,
|
||||
supports_embeddings: true,
|
||||
model_capabilities: {},
|
||||
...info,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function roleSwitch(page: Page, role: string) {
|
||||
return page.locator(`#root_${ENTRY}_roles-${role}`);
|
||||
}
|
||||
|
||||
test.describe("genai provider settings @medium", () => {
|
||||
test("a saved role the provider cannot confirm stays and is not dirty", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
// The server does not serve the saved model, so the backend reports every
|
||||
// capability as false for the entry.
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
{
|
||||
provider: "llamacpp",
|
||||
model: "stale-model",
|
||||
base_url: "http://llama:8080",
|
||||
roles: ["transcribe"],
|
||||
},
|
||||
{ models: ["qwen3-asr"], supports_transcription: false },
|
||||
);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await expect(roleSwitch(frigateApp.page, "transcribe")).toBeVisible();
|
||||
await expect(roleSwitch(frigateApp.page, "transcribe")).toBeChecked();
|
||||
|
||||
// Give any stripping effect time to fire, then confirm the section stayed
|
||||
// clean.
|
||||
await frigateApp.page.waitForTimeout(1000);
|
||||
await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden();
|
||||
await expect(roleSwitch(frigateApp.page, "transcribe")).toBeChecked();
|
||||
});
|
||||
|
||||
test("switching provider clears the model", async ({ frigateApp }) => {
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
{
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
roles: ["descriptions"],
|
||||
},
|
||||
{ models: ["gpt-4o"], supports_transcription: true },
|
||||
);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
const model = frigateApp.page.locator(`#root_${ENTRY}_model`);
|
||||
await expect(model).toHaveText("gpt-4o");
|
||||
|
||||
await frigateApp.page.locator(`#root_${ENTRY}_provider`).click();
|
||||
await frigateApp.page.getByRole("option", { name: "llamacpp" }).click();
|
||||
|
||||
await expect(model).toHaveText(MODEL_PLACEHOLDER);
|
||||
await expect(frigateApp.page.getByText(UNSAVED)).toBeVisible();
|
||||
});
|
||||
|
||||
test("switching back to the saved provider drops the other provider's model", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
{
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
roles: ["descriptions"],
|
||||
},
|
||||
{ models: ["gpt-4o", "qwen3"], supports_transcription: true },
|
||||
);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
const model = frigateApp.page.locator(`#root_${ENTRY}_model`);
|
||||
const provider = frigateApp.page.locator(`#root_${ENTRY}_provider`);
|
||||
await expect(model).toHaveText("gpt-4o");
|
||||
|
||||
await provider.click();
|
||||
await frigateApp.page.getByRole("option", { name: "llamacpp" }).click();
|
||||
await model.click();
|
||||
await frigateApp.page.getByRole("option", { name: "qwen3" }).click();
|
||||
await expect(model).toHaveText("qwen3");
|
||||
|
||||
// the model picked for llamacpp must not carry over to openai, and the
|
||||
// saved one isn't filled back in since the endpoint may have changed
|
||||
await provider.click();
|
||||
await frigateApp.page
|
||||
.getByRole("option", { name: "openai", exact: true })
|
||||
.click();
|
||||
|
||||
await expect(model).toHaveText(MODEL_PLACEHOLDER);
|
||||
});
|
||||
|
||||
test("undo after switching provider brings back the saved model", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
{
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
roles: ["descriptions"],
|
||||
},
|
||||
{ models: ["gpt-4o"], supports_transcription: true },
|
||||
);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
const model = frigateApp.page.locator(`#root_${ENTRY}_model`);
|
||||
await frigateApp.page.locator(`#root_${ENTRY}_provider`).click();
|
||||
await frigateApp.page.getByRole("option", { name: "llamacpp" }).click();
|
||||
await expect(model).toHaveText(MODEL_PLACEHOLDER);
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Undo" }).click();
|
||||
|
||||
await expect(model).toHaveText("gpt-4o");
|
||||
await expect(frigateApp.page.getByText(UNSAVED)).toBeHidden();
|
||||
});
|
||||
|
||||
test("picking a model that cannot transcribe strips the role", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
{
|
||||
provider: "llamacpp",
|
||||
model: "qwen3-asr",
|
||||
base_url: "http://llama:8080",
|
||||
roles: ["transcribe"],
|
||||
},
|
||||
{
|
||||
models: ["qwen3-asr", "text-only"],
|
||||
supports_transcription: true,
|
||||
model_capabilities: {
|
||||
"qwen3-asr": { supports_transcription: true },
|
||||
"text-only": { supports_transcription: false },
|
||||
},
|
||||
},
|
||||
);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await expect(roleSwitch(frigateApp.page, "transcribe")).toBeChecked();
|
||||
|
||||
await frigateApp.page.locator(`#root_${ENTRY}_model`).click();
|
||||
await frigateApp.page.getByRole("option", { name: "text-only" }).click();
|
||||
|
||||
await expect(roleSwitch(frigateApp.page, "transcribe")).toBeHidden();
|
||||
await expect(frigateApp.page.getByText(UNSAVED)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Runtime override tests -- MEDIUM tier.
|
||||
*
|
||||
* Live view, MQTT, and Home Assistant toggles change a camera's running config
|
||||
* without touching yaml, and the change persists across restarts. The settings
|
||||
* form edits yaml, so it keeps showing the saved value. Without a marker on the
|
||||
* field and runtime-aware wording on dependent warnings, the two read as a
|
||||
* contradiction: "audio detection is not enabled" next to a switch that is on.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { configFactory } from "../../fixtures/mock-data/config";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_SCHEMA = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
|
||||
const CAMERA = "front_door";
|
||||
const TRANSCRIPTION_URL = `/settings?page=cameraAudioTranscription&camera=${CAMERA}`;
|
||||
const AUDIO_URL = `/settings?page=cameraAudioEvents&camera=${CAMERA}`;
|
||||
|
||||
const CONFIG_DISABLED = /Audio detection is not enabled for this camera/;
|
||||
const RUNTIME_DISABLED =
|
||||
/Audio detection is enabled in your config, but it is currently turned off/;
|
||||
|
||||
type AudioState = { enabled: boolean; enabled_in_config: boolean };
|
||||
|
||||
async function installRoutes(page: Page, audio: AudioState) {
|
||||
const config = configFactory({
|
||||
cameras: {
|
||||
[CAMERA]: {
|
||||
audio,
|
||||
// audio detection only runs on a stream carrying the audio role
|
||||
ffmpeg: {
|
||||
inputs: [
|
||||
{
|
||||
path: "rtsp://user:pass@host/front",
|
||||
roles: ["record", "detect", "audio"],
|
||||
},
|
||||
],
|
||||
},
|
||||
audio_transcription: { enabled: true, enabled_in_config: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({
|
||||
json: { cameras: { [CAMERA]: { ffmpeg: { inputs: [] } } } },
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/config/schema.json", (route) =>
|
||||
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||
);
|
||||
await page.route("**/api/config", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({ json: config });
|
||||
}
|
||||
return route.fulfill({ json: { success: true } });
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("runtime overrides @medium", () => {
|
||||
test("a runtime-only toggle gets its own wording and a field marker", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, {
|
||||
enabled: false,
|
||||
enabled_in_config: true,
|
||||
});
|
||||
|
||||
await frigateApp.goto(TRANSCRIPTION_URL);
|
||||
await expect(frigateApp.page.getByText(RUNTIME_DISABLED)).toBeVisible();
|
||||
await expect(frigateApp.page.getByText(CONFIG_DISABLED)).toBeHidden();
|
||||
|
||||
// The audio section still shows the saved value, so the switch stays on and
|
||||
// the marker carries the live state.
|
||||
await frigateApp.goto(AUDIO_URL);
|
||||
await expect(
|
||||
frigateApp.page.getByRole("switch", { name: "Enable audio detection" }),
|
||||
).toHaveAttribute("data-state", "checked");
|
||||
// the boolean layout renders a mobile and a desktop label block, so only
|
||||
// one of the two badges is on screen
|
||||
await expect(
|
||||
frigateApp.page.getByText("Overridden (Live)").filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("a config-disabled section keeps the original wording", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, {
|
||||
enabled: false,
|
||||
enabled_in_config: false,
|
||||
});
|
||||
|
||||
await frigateApp.goto(TRANSCRIPTION_URL);
|
||||
await expect(frigateApp.page.getByText(CONFIG_DISABLED)).toBeVisible();
|
||||
await expect(frigateApp.page.getByText(RUNTIME_DISABLED)).toBeHidden();
|
||||
|
||||
await frigateApp.goto(AUDIO_URL);
|
||||
await expect(
|
||||
frigateApp.page.getByRole("switch", { name: "Enable audio detection" }),
|
||||
).toHaveAttribute("data-state", "unchecked");
|
||||
await expect(
|
||||
frigateApp.page.getByText("Overridden (Live)").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,15 @@ test.describe("System — tabs @medium", () => {
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await expect(frigateApp.page.getByText("0.15.0-test")).toBeVisible();
|
||||
await expect(frigateApp.page.getByText(/Last refreshed/)).toBeVisible();
|
||||
|
||||
if (frigateApp.isMobile) {
|
||||
// the "Last refreshed" label is dropped on mobile so the timestamp
|
||||
// clears the centered logo
|
||||
await expect(frigateApp.page.getByText(/Last refreshed/)).toHaveCount(0);
|
||||
await expect(frigateApp.page.getByText(/Just now|ago/)).toBeVisible();
|
||||
} else {
|
||||
await expect(frigateApp.page.getByText(/Last refreshed/)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("storage tab renders content after switching", async ({
|
||||
@@ -234,4 +242,45 @@ test.describe("System — mobile @medium @mobile", () => {
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("header controls leave the logo uncovered on a narrow phone", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.goto("/system#general");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await frigateApp.page.setViewportSize({ width: 320, height: 740 });
|
||||
|
||||
const logo = frigateApp.page.locator("svg.fill-current").first();
|
||||
const tabs = frigateApp.page
|
||||
.locator("[data-radix-scroll-area-viewport]")
|
||||
.filter({ has: frigateApp.page.getByLabel("Select general") });
|
||||
const refreshed = frigateApp.page.getByText(/Just now|ago/);
|
||||
|
||||
const logoBox = await logo.boundingBox();
|
||||
const tabsBox = await tabs.boundingBox();
|
||||
const refreshedBox = await refreshed.boundingBox();
|
||||
|
||||
expect(tabsBox!.x + tabsBox!.width).toBeLessThanOrEqual(logoBox!.x + 1);
|
||||
expect(refreshedBox!.x).toBeGreaterThanOrEqual(logoBox!.x + logoBox!.width);
|
||||
|
||||
// the clipped tabs stay reachable by scrolling
|
||||
const overflow = await tabs.evaluate((el) => ({
|
||||
scroll: el.scrollWidth,
|
||||
client: el.clientWidth,
|
||||
}));
|
||||
expect(overflow.scroll).toBeGreaterThan(overflow.client);
|
||||
await tabs.evaluate((el) => {
|
||||
el.scrollLeft = el.scrollWidth;
|
||||
});
|
||||
await frigateApp.page.getByLabel("Select cameras").click();
|
||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
"overriddenGlobalHeading_one": "This camera overrides {{count}} field from the global config:",
|
||||
"overriddenGlobalHeading_other": "This camera overrides {{count}} fields from the global config:",
|
||||
"overriddenGlobalNoDeltas": "This camera overrides the global config, but no field values differ.",
|
||||
"overriddenLive": "Overridden (Live)",
|
||||
"overriddenLiveTooltip": "This camera is running with a different value than the one saved in your config, which is shown here. The live view, MQTT, or an active profile can change it while Frigate is running.",
|
||||
"overriddenLiveValue": "Running value: {{value}}",
|
||||
"overriddenBaseConfig": "Overridden (Base Config)",
|
||||
"overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section",
|
||||
"overriddenBaseConfigHeading_one": "The {{profile}} profile overrides {{count}} field from the base config:",
|
||||
@@ -1954,20 +1957,25 @@
|
||||
"configMessages": {
|
||||
"review": {
|
||||
"recordDisabled": "Recording is disabled, review items will not be generated.",
|
||||
"recordRuntimeDisabled": "Recording is enabled in your config, but it is currently turned off for this camera, so review items will not be generated. Turn it back on from the camera's live view, or check whether an active profile is disabling it.",
|
||||
"detectDisabled": "Object detection is disabled. Review items require detected objects to categorize alerts and detections.",
|
||||
"detectRuntimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera. Review items require detected objects to categorize alerts and detections. Turn it back on from the camera's live view, or check whether an active profile is disabling it.",
|
||||
"allNonAlertDetections": "All non-alert activity will be included as detections.",
|
||||
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images."
|
||||
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.",
|
||||
"genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images."
|
||||
},
|
||||
"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.",
|
||||
"audioDetectionRuntimeDisabled": "Audio detection is enabled in your config, but it is currently turned off for this camera, so audio transcription will not run. Turn it back on from the camera's live view, or check whether an active profile is disabling it.",
|
||||
"genaiProviderSelected": "A GenAI provider is selected, so the device and model size settings are ignored."
|
||||
},
|
||||
"detect": {
|
||||
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.",
|
||||
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function.",
|
||||
"runtimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function until it is turned back on from the camera's live view, or until the active profile stops disabling it.",
|
||||
"sceneWithoutModel": "No detection model is configured for this scene, so this camera falls back to the model with a scene of 'All cameras'. Add a model for this scene to give the camera its own.",
|
||||
"resolutionShouldBeMultipleOfFour": "For best results, detect width and height should be multiples of 4. Other even values may produce visual artifacts or slight distortion in the detect stream.",
|
||||
"aspectRatioMismatch": "The width and height you've entered don't match the aspect ratio of your current detect resolution. This may produce a stretched or distorted image.",
|
||||
@@ -2002,10 +2010,12 @@
|
||||
"noRecordSubRole": "No streams have the record_sub role defined. Sub stream recording will not function."
|
||||
},
|
||||
"birdseye": {
|
||||
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye."
|
||||
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye.",
|
||||
"objectTrackingDetectRuntimeDisabled": "Birdseye includes tracked objects, but object detection is currently turned off for this camera even though your config enables it. The camera will not appear in Birdseye until it is turned back on from the camera's live view, or until the active profile stops disabling it."
|
||||
},
|
||||
"snapshots": {
|
||||
"detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created."
|
||||
"detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created.",
|
||||
"detectRuntimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera, so snapshots will not be created. Turn it back on from the camera's live view, or check whether an active profile is disabling it."
|
||||
},
|
||||
"semanticSearch": {
|
||||
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
|
||||
|
||||
@@ -133,7 +133,7 @@ export function MessageBubble({
|
||||
variant="select"
|
||||
size="icon"
|
||||
className="size-9 rounded-full"
|
||||
disabled={!draftContent.trim()}
|
||||
disabled={!draftContent.trim() || onEditSubmit == null}
|
||||
onClick={handleEditSubmit}
|
||||
aria-label={t("send")}
|
||||
>
|
||||
|
||||
@@ -9,6 +9,11 @@ const audioTranscription: SectionConfigOverrides = {
|
||||
health: (ctx) =>
|
||||
ctx.fullCameraConfig?.audio_transcription?.enabled === true,
|
||||
messageKey: "configMessages.audioTranscription.audioDetectionDisabled",
|
||||
runtimeOverride: {
|
||||
section: "audio",
|
||||
messageKey:
|
||||
"configMessages.audioTranscription.audioDetectionRuntimeDisabled",
|
||||
},
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
|
||||
@@ -7,6 +7,11 @@ const birdseye: SectionConfigOverrides = {
|
||||
{
|
||||
key: "object-tracking-detect-disabled",
|
||||
messageKey: "configMessages.birdseye.objectTrackingDetectDisabled",
|
||||
runtimeOverride: {
|
||||
section: "detect",
|
||||
messageKey:
|
||||
"configMessages.birdseye.objectTrackingDetectRuntimeDisabled",
|
||||
},
|
||||
severity: "info",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||
|
||||
@@ -63,6 +63,10 @@ const objects: SectionConfigOverrides = {
|
||||
{
|
||||
key: "detect-disabled",
|
||||
messageKey: "configMessages.detect.disabled",
|
||||
runtimeOverride: {
|
||||
section: "detect",
|
||||
messageKey: "configMessages.detect.runtimeDisabled",
|
||||
},
|
||||
severity: "info",
|
||||
condition: (ctx) =>
|
||||
ctx.level === "camera" &&
|
||||
|
||||
@@ -7,6 +7,10 @@ const review: SectionConfigOverrides = {
|
||||
{
|
||||
key: "record-disabled",
|
||||
messageKey: "configMessages.review.recordDisabled",
|
||||
runtimeOverride: {
|
||||
section: "record",
|
||||
messageKey: "configMessages.review.recordRuntimeDisabled",
|
||||
},
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
@@ -18,6 +22,10 @@ const review: SectionConfigOverrides = {
|
||||
{
|
||||
key: "detect-disabled",
|
||||
messageKey: "configMessages.review.detectDisabled",
|
||||
runtimeOverride: {
|
||||
section: "detect",
|
||||
messageKey: "configMessages.review.detectRuntimeDisabled",
|
||||
},
|
||||
severity: "info",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
@@ -64,6 +72,11 @@ const review: SectionConfigOverrides = {
|
||||
field: "genai.image_source",
|
||||
messageKey:
|
||||
"configMessages.review.genaiImageSourceRecordingsRecordDisabled",
|
||||
runtimeOverride: {
|
||||
section: "record",
|
||||
messageKey:
|
||||
"configMessages.review.genaiImageSourceRecordingsRecordRuntimeDisabled",
|
||||
},
|
||||
severity: "warning",
|
||||
position: "after",
|
||||
condition: (ctx) => {
|
||||
|
||||
@@ -7,6 +7,10 @@ const snapshots: SectionConfigOverrides = {
|
||||
{
|
||||
key: "detect-disabled",
|
||||
messageKey: "configMessages.snapshots.detectDisabled",
|
||||
runtimeOverride: {
|
||||
section: "detect",
|
||||
messageKey: "configMessages.snapshots.detectRuntimeDisabled",
|
||||
},
|
||||
severity: "info",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||
|
||||
@@ -28,6 +28,17 @@ export type ConditionalMessage = {
|
||||
values?: Record<string, unknown>;
|
||||
/** Optional documentation path (e.g. "/configuration/object_detectors#model"). */
|
||||
docLink?: string;
|
||||
/**
|
||||
* Alternate wording for when the section this message depends on is enabled
|
||||
* in the config but turned off on the running camera. Without it the message
|
||||
* reads as a contradiction, since the form shows the saved config value.
|
||||
*/
|
||||
runtimeOverride?: {
|
||||
/** Camera section whose runtime state explains the message, e.g. "audio". */
|
||||
section: string;
|
||||
/** Translation key used in place of `messageKey`. */
|
||||
messageKey: string;
|
||||
};
|
||||
/**
|
||||
* Whether the Health tab evaluates this message against the saved config.
|
||||
* Absent or false: form only. true: shown whenever condition() holds. A
|
||||
|
||||
@@ -1022,6 +1022,7 @@ export function ConfigSection({
|
||||
formContext={{
|
||||
level: effectiveLevel,
|
||||
cameraName,
|
||||
sectionPath,
|
||||
globalValue,
|
||||
cameraValue,
|
||||
hasChanges,
|
||||
|
||||
@@ -19,6 +19,8 @@ import { LuExternalLink } from "react-icons/lu";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { requiresRestartForFieldPath } from "@/utils/configUtil";
|
||||
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
|
||||
import RuntimeOverrideIndicator from "@/components/indicators/RuntimeOverrideIndicator";
|
||||
import { getRuntimeOverride } from "@/utils/runtimeOverrides";
|
||||
import {
|
||||
buildTranslationPath,
|
||||
resolveConfigTranslation,
|
||||
@@ -211,6 +213,18 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
defaultRequiresRestart,
|
||||
);
|
||||
|
||||
// The form shows saved config values, so flag any field the running camera
|
||||
// currently disagrees with. Profile editing shows that profile's overrides
|
||||
// instead, where the comparison does not apply.
|
||||
const runtimeOverride =
|
||||
isCameraLevel && !formContext?.isProfile
|
||||
? getRuntimeOverride(
|
||||
formContext?.fullCameraConfig,
|
||||
formContext?.sectionPath,
|
||||
pathSegments.join("."),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Use schema title/description as primary source (from JSON Schema)
|
||||
const schemaTitle = schema.title;
|
||||
const schemaDescription = schema.description;
|
||||
@@ -502,6 +516,12 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
{finalLabel}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||
{runtimeOverride && (
|
||||
<RuntimeOverrideIndicator
|
||||
runtimeValue={runtimeOverride.runtime}
|
||||
className="ml-2"
|
||||
/>
|
||||
)}
|
||||
</Label>
|
||||
);
|
||||
};
|
||||
@@ -519,6 +539,12 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
{finalLabel}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||
{runtimeOverride && (
|
||||
<RuntimeOverrideIndicator
|
||||
runtimeValue={runtimeOverride.runtime}
|
||||
className="ml-2"
|
||||
/>
|
||||
)}
|
||||
</Label>
|
||||
);
|
||||
};
|
||||
@@ -540,6 +566,12 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
{finalLabel}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||
{runtimeOverride && (
|
||||
<RuntimeOverrideIndicator
|
||||
runtimeValue={runtimeOverride.runtime}
|
||||
className="ml-2"
|
||||
/>
|
||||
)}
|
||||
</Label>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -59,20 +59,32 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
|
||||
// Build a fingerprint from the saved config's provider + base_url so the
|
||||
// SWR key changes (and models are refetched) whenever those fields are saved.
|
||||
const configFingerprint = useMemo(() => {
|
||||
if (!providerKey) return "";
|
||||
const savedEntry = useMemo<Record<string, unknown> | null>(() => {
|
||||
if (!providerKey) return null;
|
||||
const genai = (
|
||||
formContext?.fullConfig as Record<string, unknown> | undefined
|
||||
)?.genai;
|
||||
if (!genai || typeof genai !== "object" || Array.isArray(genai)) return "";
|
||||
if (!genai || typeof genai !== "object" || Array.isArray(genai)) {
|
||||
return null;
|
||||
}
|
||||
const entry = (genai as Record<string, unknown>)[providerKey];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return "";
|
||||
const e = entry as Record<string, unknown>;
|
||||
return `${e.provider ?? ""}|${e.base_url ?? ""}`;
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return null;
|
||||
}
|
||||
return entry as Record<string, unknown>;
|
||||
}, [providerKey, formContext?.fullConfig]);
|
||||
|
||||
const savedProvider =
|
||||
typeof savedEntry?.provider === "string" ? savedEntry.provider : null;
|
||||
const savedModel =
|
||||
typeof savedEntry?.model === "string" ? savedEntry.model : "";
|
||||
|
||||
// Build a fingerprint from the saved config's provider + base_url so the
|
||||
// SWR key changes (and models are refetched) whenever those fields are saved.
|
||||
const configFingerprint = savedEntry
|
||||
? `${savedEntry.provider ?? ""}|${savedEntry.base_url ?? ""}`
|
||||
: "";
|
||||
|
||||
const { data: allModels, mutate: mutateModels } = useSWR<GenAIModelsResponse>(
|
||||
"genai/models",
|
||||
{
|
||||
@@ -148,6 +160,18 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
typeof formEntry?.provider === "string" ? formEntry.provider : null;
|
||||
const canProbe = Boolean(formProvider) && !probing;
|
||||
|
||||
// A model name belongs to its provider, so switching provider clears it,
|
||||
// unless the form holds the saved provider and model together
|
||||
const prevFormProvider = useRef(formProvider);
|
||||
useEffect(() => {
|
||||
const previous = prevFormProvider.current;
|
||||
prevFormProvider.current = formProvider;
|
||||
|
||||
if (previous === formProvider) return;
|
||||
if (formProvider === savedProvider && value === savedModel) return;
|
||||
if (typeof value === "string" && value) onChange("");
|
||||
}, [formProvider, savedProvider, savedModel, value, onChange]);
|
||||
|
||||
const probe = async () => {
|
||||
if (!formEntry || !formProvider) return;
|
||||
if (probeSuccessTimerRef.current) {
|
||||
|
||||
@@ -25,6 +25,22 @@ function normalizeValue(value: unknown): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function getString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value ? value : undefined;
|
||||
}
|
||||
|
||||
function getEntry(
|
||||
entries: unknown,
|
||||
providerKey: string | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!providerKey || !entries || typeof entries !== "object") return undefined;
|
||||
const entry = (entries as Record<string, unknown>)[providerKey];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return undefined;
|
||||
}
|
||||
return entry as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getProviderKey(widgetId: string): string | undefined {
|
||||
const prefix = "root_";
|
||||
const suffix = "_roles";
|
||||
@@ -51,21 +67,26 @@ export function GenAIRolesWidget(props: WidgetProps) {
|
||||
// The model currently chosen in the form, which is what the roles have to
|
||||
// reflect. Reading the saved config instead would keep reporting the previous
|
||||
// model's capabilities until a save and a refetch.
|
||||
const selectedModel = useMemo(() => {
|
||||
if (!providerKey) return undefined;
|
||||
const formData = formContext?.formData as
|
||||
Record<string, unknown> | undefined;
|
||||
const entry = formData?.[providerKey];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return undefined;
|
||||
}
|
||||
const model = (entry as Record<string, unknown>).model;
|
||||
return typeof model === "string" && model ? model : undefined;
|
||||
}, [formContext?.formData, providerKey]);
|
||||
const formEntry = useMemo(
|
||||
() => getEntry(formContext?.formData, providerKey),
|
||||
[formContext?.formData, providerKey],
|
||||
);
|
||||
const savedEntry = useMemo(
|
||||
() => getEntry(formContext?.fullConfig?.genai, providerKey),
|
||||
[formContext?.fullConfig?.genai, providerKey],
|
||||
);
|
||||
|
||||
const selectedModel = getString(formEntry?.model);
|
||||
|
||||
// The entry-level capability flags describe the saved provider and model
|
||||
// only, so they apply while the form still matches the saved entry.
|
||||
const matchesSaved =
|
||||
savedEntry !== undefined &&
|
||||
getString(formEntry?.provider) === getString(savedEntry.provider) &&
|
||||
selectedModel === getString(savedEntry.model);
|
||||
|
||||
// Capabilities the provider reported for that specific model. Absent when the
|
||||
// provider cannot describe a model it has not loaded, in which case the
|
||||
// entry-level flags (which describe the saved model) are the best available.
|
||||
// provider cannot describe a model it has not loaded.
|
||||
const modelCapabilities: GenAIModelCapabilities | undefined = useMemo(() => {
|
||||
if (!providerKey || !selectedModel) return undefined;
|
||||
return genaiInfo?.[providerKey]?.model_capabilities?.[selectedModel];
|
||||
@@ -76,7 +97,7 @@ export function GenAIRolesWidget(props: WidgetProps) {
|
||||
): boolean => {
|
||||
const perModel = modelCapabilities?.[key];
|
||||
if (perModel !== undefined) return perModel;
|
||||
if (!providerKey) return true;
|
||||
if (!providerKey || !matchesSaved) return true;
|
||||
const info = genaiInfo?.[providerKey];
|
||||
// assume supported when nothing is known, so a role is never hidden on
|
||||
// missing information alone
|
||||
@@ -95,9 +116,13 @@ export function GenAIRolesWidget(props: WidgetProps) {
|
||||
return unsupported;
|
||||
}, [embeddingsSupported, transcriptionSupported]);
|
||||
|
||||
// a selected role stays visible so it can still be switched off
|
||||
const availableRoles = useMemo(
|
||||
() => GENAI_ROLES.filter((role) => !unsupportedRoles.has(role)),
|
||||
[unsupportedRoles],
|
||||
() =>
|
||||
GENAI_ROLES.filter(
|
||||
(role) => !unsupportedRoles.has(role) || selectedRoles.includes(role),
|
||||
),
|
||||
[unsupportedRoles, selectedRoles],
|
||||
);
|
||||
|
||||
const occupiedRoles = useMemo(() => {
|
||||
@@ -123,13 +148,16 @@ export function GenAIRolesWidget(props: WidgetProps) {
|
||||
return occupied;
|
||||
}, [formContext?.formData, providerKey]);
|
||||
|
||||
// strip every unsupported role in a single onChange; two effects each
|
||||
// rewriting the same value would race and lose one of the edits
|
||||
// Strip every unsupported role in a single onChange; two effects each
|
||||
// rewriting the same value would race and lose one of the edits. Only a
|
||||
// model or provider picked in the form can rule a role out, so capability
|
||||
// data arriving for the saved entry never edits the form on its own.
|
||||
useEffect(() => {
|
||||
if (matchesSaved) return;
|
||||
if (!selectedRoles.some((role) => unsupportedRoles.has(role))) return;
|
||||
|
||||
onChange(selectedRoles.filter((role) => !unsupportedRoles.has(role)));
|
||||
}, [unsupportedRoles, selectedRoles, onChange]);
|
||||
}, [matchesSaved, unsupportedRoles, selectedRoles, onChange]);
|
||||
|
||||
const toggleRole = (role: string, enabled: boolean) => {
|
||||
if (enabled) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tooltip, TooltipContent } from "../ui/tooltip";
|
||||
import { TooltipTrigger } from "@radix-ui/react-tooltip";
|
||||
|
||||
type RuntimeOverrideIndicatorProps = {
|
||||
/** The value the camera is running with, which differs from the saved config. */
|
||||
runtimeValue: unknown;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Field-level companion to the section override badges. Marks a field the
|
||||
* running camera has drifted from, so the saved value on screen never reads as
|
||||
* the live one.
|
||||
*/
|
||||
export default function RuntimeOverrideIndicator({
|
||||
runtimeValue,
|
||||
className,
|
||||
}: RuntimeOverrideIndicatorProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
|
||||
const displayValue =
|
||||
typeof runtimeValue === "boolean"
|
||||
? t(runtimeValue ? "button.on" : "button.off", { ns: "common" })
|
||||
: Array.isArray(runtimeValue)
|
||||
? runtimeValue.join(", ")
|
||||
: String(runtimeValue);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"cursor-default border-2 border-selected text-center align-middle text-xs font-normal text-primary-variant",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{t("button.overriddenLive", { ns: "views/settings" })}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-72">
|
||||
<p>{t("button.overriddenLiveTooltip", { ns: "views/settings" })}</p>
|
||||
<p className="mt-1">
|
||||
{t("button.overriddenLiveValue", {
|
||||
ns: "views/settings",
|
||||
value: displayValue,
|
||||
})}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
FieldConditionalMessage,
|
||||
MessageConditionContext,
|
||||
} from "@/components/config-form/section-configs/types";
|
||||
import { resolveMessageKey } from "@/utils/runtimeOverrides";
|
||||
|
||||
export function useConfigMessages(
|
||||
messages: ConditionalMessage[] | undefined,
|
||||
@@ -15,12 +16,16 @@ export function useConfigMessages(
|
||||
} {
|
||||
const activeMessages = useMemo(() => {
|
||||
if (!messages || !context) return [];
|
||||
return messages.filter((msg) => msg.condition(context));
|
||||
return messages
|
||||
.filter((msg) => msg.condition(context))
|
||||
.map((msg) => ({ ...msg, messageKey: resolveMessageKey(msg, context) }));
|
||||
}, [messages, context]);
|
||||
|
||||
const activeFieldMessages = useMemo(() => {
|
||||
if (!fieldMessages || !context) return [];
|
||||
return fieldMessages.filter((msg) => msg.condition(context));
|
||||
return fieldMessages
|
||||
.filter((msg) => msg.condition(context))
|
||||
.map((msg) => ({ ...msg, messageKey: resolveMessageKey(msg, context) }));
|
||||
}, [fieldMessages, context]);
|
||||
|
||||
return { activeMessages, activeFieldMessages };
|
||||
|
||||
@@ -7,6 +7,16 @@ export type KeyModifiers = {
|
||||
shift: boolean;
|
||||
};
|
||||
|
||||
const handledByShortcut = new WeakSet<Event>();
|
||||
|
||||
// Radix dismisses a dialog or menu on Escape from a capture-phase listener and
|
||||
// calls preventDefault() without stopping propagation, so a page shortcut would
|
||||
// otherwise act on the same press. Keys another shortcut hook handled still get
|
||||
// through, since their listener order changes with every render.
|
||||
function handledElsewhere(event: KeyboardEvent): boolean {
|
||||
return event.defaultPrevented && !handledByShortcut.has(event);
|
||||
}
|
||||
|
||||
export default function useKeyboardListener(
|
||||
keys: string[],
|
||||
listener?: (key: string | null, modifiers: KeyModifiers) => boolean,
|
||||
@@ -27,6 +37,10 @@ export default function useKeyboardListener(
|
||||
return;
|
||||
}
|
||||
|
||||
if (handledElsewhere(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modifiers = {
|
||||
down: true,
|
||||
repeat: e.repeat,
|
||||
@@ -63,7 +77,10 @@ export default function useKeyboardListener(
|
||||
}
|
||||
} else if (keys.includes(e.key) && listener) {
|
||||
const preventDefault = listener(e.key, modifiers);
|
||||
if (preventDefault) e.preventDefault();
|
||||
if (preventDefault) {
|
||||
e.preventDefault();
|
||||
handledByShortcut.add(e);
|
||||
}
|
||||
} else if (
|
||||
listener &&
|
||||
(e.key === "Shift" || e.key === "Control" || e.key === "Meta")
|
||||
|
||||
@@ -362,7 +362,7 @@ export default function ChatPage() {
|
||||
role="user"
|
||||
content={msg.content}
|
||||
messageIndex={i}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onEditSubmit={isLoading ? undefined : handleEditSubmit}
|
||||
isComplete
|
||||
showStats={showStats}
|
||||
/>
|
||||
|
||||
+39
-29
@@ -24,6 +24,8 @@ import HealthMetrics from "@/views/system/HealthMetrics";
|
||||
import NoticeFilterButton from "@/components/health/NoticeFilterButton";
|
||||
import { DEFAULT_NOTICE_FILTER, NoticeFilter } from "@/types/health";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const allMetrics = [
|
||||
"health",
|
||||
@@ -96,35 +98,43 @@ function System() {
|
||||
{isMobile && (
|
||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||
)}
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
type="single"
|
||||
size="sm"
|
||||
value={pageToggle}
|
||||
onValueChange={(value: SystemMetric) => {
|
||||
if (value) {
|
||||
setPageToggle(value);
|
||||
}
|
||||
}} // don't allow the severity to be unselected
|
||||
>
|
||||
{Object.values(metrics).map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item}
|
||||
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
value={item}
|
||||
aria-label={`Select ${item}`}
|
||||
<ScrollArea className={cn("whitespace-nowrap", isMobile && "w-[45%]")}>
|
||||
<div className="flex flex-row">
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
type="single"
|
||||
size="sm"
|
||||
value={pageToggle}
|
||||
onValueChange={(value: SystemMetric) => {
|
||||
if (value) {
|
||||
setPageToggle(value);
|
||||
}
|
||||
}} // don't allow the severity to be unselected
|
||||
>
|
||||
{item == "health" && <LuHeartPulse className="size-4" />}
|
||||
{item == "general" && <LuActivity className="size-4" />}
|
||||
{item == "enrichments" && <LuSearchCode className="size-4" />}
|
||||
{item == "storage" && <LuHardDrive className="size-4" />}
|
||||
{item == "cameras" && <FaVideo className="size-4" />}
|
||||
{isDesktop && (
|
||||
<div className="smart-capitalize">{t(item + ".title")}</div>
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
{Object.values(metrics).map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item}
|
||||
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
value={item}
|
||||
aria-label={t("selectItem", {
|
||||
ns: "common",
|
||||
item: t(item + ".title"),
|
||||
})}
|
||||
>
|
||||
{item == "health" && <LuHeartPulse className="size-4" />}
|
||||
{item == "general" && <LuActivity className="size-4" />}
|
||||
{item == "enrichments" && <LuSearchCode className="size-4" />}
|
||||
{item == "storage" && <LuHardDrive className="size-4" />}
|
||||
{item == "cameras" && <FaVideo className="size-4" />}
|
||||
{isDesktop && (
|
||||
<div className="smart-capitalize">{t(item + ".title")}</div>
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<ScrollBar orientation="horizontal" className="h-0" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex h-full items-center">
|
||||
{pageToggle == "health" && (
|
||||
@@ -135,7 +145,7 @@ function System() {
|
||||
)}
|
||||
{lastUpdated && pageToggle != "health" && (
|
||||
<div className="h-full content-center text-sm text-muted-foreground">
|
||||
{t("lastRefreshed")}
|
||||
{isDesktop && t("lastRefreshed")}
|
||||
<TimeAgo time={lastUpdated * 1000} dense />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,8 @@ export type HiddenFieldEntry = string | ((ctx: HiddenFieldContext) => string[]);
|
||||
export type ConfigFormContext = {
|
||||
level?: "global" | "camera";
|
||||
cameraName?: string;
|
||||
/** Config section being edited, e.g. "audio" or "review". */
|
||||
sectionPath?: string;
|
||||
globalValue?: JsonValue;
|
||||
cameraValue?: JsonValue;
|
||||
overrides?: JsonValue;
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { HealthProblem } from "@/types/health";
|
||||
import { getSectionConfig } from "@/utils/configUtil";
|
||||
import { activeCameras } from "@/utils/health";
|
||||
import { resolveMessageKey } from "@/utils/runtimeOverrides";
|
||||
|
||||
function healthMessages(
|
||||
section: string,
|
||||
@@ -47,7 +48,7 @@ function toProblem(
|
||||
severity: message.severity,
|
||||
scope,
|
||||
scopeIsCamera,
|
||||
text: t(message.messageKey, {
|
||||
text: t(resolveMessageKey(message, ctx), {
|
||||
ns: "views/settings",
|
||||
...(message.values ?? {}),
|
||||
}),
|
||||
|
||||
@@ -13,6 +13,7 @@ import set from "lodash/set";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { applyConfiguredToggles } from "@/utils/runtimeOverrides";
|
||||
import { normalizeConfigValue } from "@/hooks/use-config-override";
|
||||
import {
|
||||
modifySchemaForSection,
|
||||
@@ -103,11 +104,13 @@ export const globalCameraDefaultSections = new Set([
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the base (pre-profile) value for a camera section.
|
||||
* Get the saved-config value for a camera section, which is what the settings
|
||||
* form edits.
|
||||
*
|
||||
* When a profile is active the API populates `base_config` with original
|
||||
* section values. This helper returns that value when available, falling
|
||||
* back to the top-level (effective) value otherwise.
|
||||
* Two things move the top-level (effective) value away from yaml. A profile
|
||||
* merges its overrides into it, and the API then populates `base_config` with
|
||||
* the originals. Runtime toggles from the live view, MQTT, or Home Assistant
|
||||
* change it in place, and `applyConfiguredToggles` puts those fields back.
|
||||
*/
|
||||
export function getBaseCameraSectionValue(
|
||||
config: FrigateConfig | undefined,
|
||||
@@ -118,7 +121,11 @@ export function getBaseCameraSectionValue(
|
||||
const cam = config.cameras?.[cameraName];
|
||||
if (!cam) return undefined;
|
||||
const base = cam.base_config?.[sectionPath];
|
||||
return base !== undefined ? base : get(cam, sectionPath);
|
||||
return applyConfiguredToggles(
|
||||
cam,
|
||||
sectionPath,
|
||||
base !== undefined ? base : get(cam, sectionPath),
|
||||
);
|
||||
}
|
||||
|
||||
// mergeWith customizer that replaces arrays wholesale instead of merging them
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import get from "lodash/get";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import set from "lodash/set";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import type { CameraConfig } from "@/types/frigateConfig";
|
||||
import type {
|
||||
ConditionalMessage,
|
||||
MessageConditionContext,
|
||||
} from "@/components/config-form/section-configs/types";
|
||||
|
||||
/**
|
||||
* Camera fields the dispatcher can change at runtime from the live view, MQTT,
|
||||
* or Home Assistant. Mirrors the camera command handlers in
|
||||
* frigate/comms/dispatcher.py. Runtime changes persist across restarts and win
|
||||
* over yaml until the field is saved again, so the settings form (which edits
|
||||
* yaml) has to show the config value and flag the divergence.
|
||||
*
|
||||
* Paths are relative to the section.
|
||||
*/
|
||||
export const RUNTIME_TOGGLEABLE_FIELDS: Record<string, string[]> = {
|
||||
audio: ["enabled"],
|
||||
birdseye: ["enabled", "modes"],
|
||||
detect: ["enabled"],
|
||||
motion: ["enabled", "improve_contrast", "threshold", "contour_area"],
|
||||
notifications: ["enabled"],
|
||||
objects: ["genai.enabled"],
|
||||
onvif: ["autotracking.enabled"],
|
||||
record: ["enabled"],
|
||||
review: ["alerts.enabled", "detections.enabled", "genai.enabled"],
|
||||
snapshots: ["enabled"],
|
||||
};
|
||||
|
||||
/**
|
||||
* The value a field holds in yaml, or undefined when the backend exposes no
|
||||
* config-side copy of it.
|
||||
*
|
||||
* Two sources carry it. `base_config` is the pre-profile snapshot, sent only
|
||||
* while a profile is active. The `<field>_in_config` siblings are always sent,
|
||||
* but only exist for the toggles the backend tracks that way (`detect`,
|
||||
* `snapshots`, and `birdseye` have none).
|
||||
*/
|
||||
export function getConfiguredFieldValue(
|
||||
cameraConfig: CameraConfig | undefined,
|
||||
sectionPath: string,
|
||||
fieldPath: string,
|
||||
): unknown {
|
||||
if (!cameraConfig) return undefined;
|
||||
|
||||
const inConfig = get(cameraConfig, `${sectionPath}.${fieldPath}_in_config`);
|
||||
if (inConfig !== undefined && inConfig !== null) {
|
||||
return inConfig;
|
||||
}
|
||||
|
||||
const base = cameraConfig.base_config?.[sectionPath];
|
||||
return base !== undefined ? get(base, fieldPath) : undefined;
|
||||
}
|
||||
|
||||
export type RuntimeOverride = {
|
||||
/** The value saved in yaml. */
|
||||
configured: unknown;
|
||||
/** The value the camera is running with right now. */
|
||||
runtime: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes a field whose live value has drifted from the saved config, or
|
||||
* undefined when the two agree or the config value can't be read.
|
||||
*/
|
||||
export function getRuntimeOverride(
|
||||
cameraConfig: CameraConfig | undefined,
|
||||
sectionPath: string | undefined,
|
||||
fieldPath: string,
|
||||
): RuntimeOverride | undefined {
|
||||
if (!cameraConfig || !sectionPath) return undefined;
|
||||
if (!RUNTIME_TOGGLEABLE_FIELDS[sectionPath]?.includes(fieldPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const configured = getConfiguredFieldValue(
|
||||
cameraConfig,
|
||||
sectionPath,
|
||||
fieldPath,
|
||||
);
|
||||
if (configured === undefined) return undefined;
|
||||
|
||||
const runtime = get(cameraConfig, `${sectionPath}.${fieldPath}`);
|
||||
if (runtime === undefined || isEqual(configured, runtime)) return undefined;
|
||||
|
||||
return { configured, runtime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a section is enabled in yaml but turned off on the running camera.
|
||||
* This is the state that makes a dependent warning read as a contradiction,
|
||||
* since the form shows the section switch on.
|
||||
*/
|
||||
export function isSectionRuntimeDisabled(
|
||||
cameraConfig: CameraConfig | undefined,
|
||||
sectionPath: string,
|
||||
): boolean {
|
||||
const override = getRuntimeOverride(cameraConfig, sectionPath, "enabled");
|
||||
return override?.configured === true && override.runtime === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlays the saved config values onto a section so the form edits yaml
|
||||
* rather than live state. Returns the section unchanged when nothing drifted.
|
||||
*/
|
||||
export function applyConfiguredToggles(
|
||||
cameraConfig: CameraConfig | undefined,
|
||||
sectionPath: string,
|
||||
sectionValue: unknown,
|
||||
): unknown {
|
||||
const fields = RUNTIME_TOGGLEABLE_FIELDS[sectionPath];
|
||||
if (!fields || !sectionValue || typeof sectionValue !== "object") {
|
||||
return sectionValue;
|
||||
}
|
||||
|
||||
let result = sectionValue;
|
||||
for (const field of fields) {
|
||||
const override = getRuntimeOverride(cameraConfig, sectionPath, field);
|
||||
if (!override) continue;
|
||||
|
||||
if (result === sectionValue) {
|
||||
result = cloneDeep(sectionValue);
|
||||
}
|
||||
set(result as object, field, override.configured);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the wording for a message. A message that depends on another section
|
||||
* being on switches to its runtime wording when the config has that section
|
||||
* enabled but the running camera has it off.
|
||||
*/
|
||||
export function resolveMessageKey(
|
||||
message: Pick<ConditionalMessage, "messageKey" | "runtimeOverride">,
|
||||
ctx: MessageConditionContext | undefined,
|
||||
): string {
|
||||
const runtime = message.runtimeOverride;
|
||||
if (!runtime || !ctx || ctx.level !== "camera") return message.messageKey;
|
||||
|
||||
return isSectionRuntimeDisabled(ctx.fullCameraConfig, runtime.section)
|
||||
? runtime.messageKey
|
||||
: message.messageKey;
|
||||
}
|
||||
@@ -899,6 +899,14 @@ type TrainGridProps = {
|
||||
onRefresh: () => void;
|
||||
onDelete: (ids: string[]) => void;
|
||||
};
|
||||
|
||||
// the backend writes a class with a "-" as "_" in train file names, since it
|
||||
// splits those names on "-", so a dataset class may still carry the dash
|
||||
function matchesTrainClass(classes: string[], name: string): boolean {
|
||||
const target = name.replaceAll("-", "_");
|
||||
return classes.some((item) => item.replaceAll("-", "_") === target);
|
||||
}
|
||||
|
||||
function TrainGrid({
|
||||
model,
|
||||
contentRef,
|
||||
@@ -936,7 +944,10 @@ function TrainGrid({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (trainFilter.classes && !trainFilter.classes.includes(data.name)) {
|
||||
if (
|
||||
trainFilter.classes &&
|
||||
!matchesTrainClass(trainFilter.classes, data.name)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user