Compare commits

...
9 Commits
Author SHA1 Message Date
Josh Hawkins e0d48c98d9 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.
2026-09-17 20:42:41 -05:00
Josh Hawkins 1ba10bce2a 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.
2026-09-17 20:42:15 -05:00
Josh Hawkins 8120a4b117 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.
2026-09-17 16:55:39 -05:00
Josh Hawkins 03e6f54670 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.
2026-09-17 16:55:39 -05:00
Josh Hawkins 13a3af1957 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.
2026-09-17 16:55:31 -05:00
Josh Hawkins 8aa0ddf740 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.
2026-09-17 16:55:25 -05:00
Josh Hawkins 5fc8d4f1c6 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.
2026-09-17 16:55:19 -05:00
Josh Hawkins a8c838aee2 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.
2026-09-17 16:55:12 -05:00
Josh Hawkins 4080c46428 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.
2026-09-17 16:55:06 -05:00
25 changed files with 706 additions and 99 deletions
+42 -21
View File
@@ -258,15 +258,15 @@ async def latest_frame(
frame = request.app.camera_error_image 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: if frame is None:
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Unable to get valid frame"}, content={"success": False, "message": "Unable to get valid frame"},
status_code=500, 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: if height < 1 or width < 1:
return JSONResponse( return JSONResponse(
content="Invalid height / width requested :: {} / {}".format( 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 the recordings are not found and the event started more than 5 minutes ago, set has_clip to false
if ( if (
event.start_time < datetime.now().timestamp() - 300 event.start_time < datetime.now().timestamp() - 300
and type(vod_response) is tuple and isinstance(vod_response, JSONResponse)
and len(vod_response) == 2 and vod_response.status_code == 404
and vod_response[1] == 404
): ):
Event.update(has_clip=False).where(Event.id == event_id).execute() Event.update(has_clip=False).where(Event.id == event_id).execute()
@@ -956,15 +955,23 @@ async def event_snapshot(
event_complete = False event_complete = False
jpg_bytes = None jpg_bytes = None
frame_time = 0 frame_time = 0
try: try:
event = Event.get(Event.id == event_id, Event.end_time != None) event = Event.get(Event.id == event_id, Event.end_time != None)
event_complete = True
await require_camera_access(event.camera, request=request) 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: if not event.has_snapshot:
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Snapshot not available"}, content={"success": False, "message": "Snapshot not available"},
status_code=404, status_code=404,
) )
try:
snapshot_settings = _resolve_snapshot_settings( snapshot_settings = _resolve_snapshot_settings(
request.app.frigate_config.cameras[event.camera].snapshots, params request.app.frigate_config.cameras[event.camera].snapshots, params
) )
@@ -979,18 +986,30 @@ async def event_snapshot(
timestamp_style=request.app.frigate_config.cameras[ timestamp_style=request.app.frigate_config.cameras[
event.camera event.camera
].timestamp_style, ].timestamp_style,
colormap=request.app.frigate_config.model_for_camera(event.camera).colormap, colormap=request.app.frigate_config.model_for_camera(
event.camera
).colormap,
) )
except DoesNotExist: except Exception:
return JSONResponse(
content={"success": False, "message": "Unknown error occurred"},
status_code=404,
)
else:
# see if the object is currently being tracked # see if the object is currently being tracked
try:
camera_states: list[CameraState] = ( camera_states: list[CameraState] = (
request.app.detected_frames_processor.get_camera_states() request.app.detected_frames_processor.get_camera_states()
) )
for camera_state in 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) tracked_obj = camera_state.tracked_objects.get(event_id)
if tracked_obj is not None:
if tracked_obj is None:
continue
await require_camera_access(camera_state.name, request=request)
try:
snapshot_settings = _resolve_snapshot_settings( snapshot_settings = _resolve_snapshot_settings(
camera_state.camera_config.snapshots, params camera_state.camera_config.snapshots, params
) )
@@ -1002,17 +1021,13 @@ async def event_snapshot(
height=snapshot_settings["height"], height=snapshot_settings["height"],
quality=snapshot_settings["quality"], quality=snapshot_settings["quality"],
) )
await require_camera_access(camera_state.name, request=request)
except Exception: except Exception:
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Ongoing event not found"}, content={"success": False, "message": "Ongoing event not found"},
status_code=404, status_code=404,
) )
except Exception:
return JSONResponse( break
content={"success": False, "message": "Unknown error occurred"},
status_code=404,
)
if jpg_bytes is None: if jpg_bytes is None:
return JSONResponse( return JSONResponse(
@@ -1062,13 +1077,17 @@ async def event_thumbnail(
if not thumbnail_bytes: if not thumbnail_bytes:
# see if the object is currently being tracked # see if the object is currently being tracked
try:
camera_states = request.app.detected_frames_processor.get_camera_states() camera_states = request.app.detected_frames_processor.get_camera_states()
for camera_state in 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) tracked_obj = camera_state.tracked_objects.get(event_id)
if tracked_obj is not None:
if tracked_obj is None:
continue
await require_camera_access(camera_state.name, request=request) await require_camera_access(camera_state.name, request=request)
try:
thumbnail_bytes = tracked_obj.get_thumbnail(extension.value) thumbnail_bytes = tracked_obj.get_thumbnail(extension.value)
except Exception: except Exception:
return JSONResponse( return JSONResponse(
@@ -1076,6 +1095,8 @@ async def event_thumbnail(
status_code=404, status_code=404,
) )
break
if not thumbnail_bytes: if not thumbnail_bytes:
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Event not found"}, content={"success": False, "message": "Event not found"},
+54
View File
@@ -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)
+55
View File
@@ -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)
+8 -1
View File
@@ -35,11 +35,18 @@ logger = logging.getLogger(__name__)
def restart_frigate(): def restart_frigate():
proc = psutil.Process(1) proc = psutil.Process(1)
# if this is running via s6, sigterm pid 1 # if this is running via s6, sigterm pid 1
if proc.name() == "s6-svscan": if proc.name() == "s6-svscan":
try:
proc.terminate() 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 # otherwise, just try and exit frigate
else:
os.kill(os.getpid(), signal.SIGINT) os.kill(os.getpid(), signal.SIGINT)
+40 -19
View File
@@ -2,6 +2,7 @@
import datetime import datetime
import logging import logging
import math
from zoneinfo import ZoneInfoNotFoundError from zoneinfo import ZoneInfoNotFoundError
import pytz import pytz
@@ -43,9 +44,33 @@ def is_current_hour(timestamp: int) -> bool:
return timestamp < start_of_next_hour 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( def get_dst_transitions(
tz_name: str, start_time: float, end_time: float 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. Find DST transition points and return time periods with consistent offsets.
@@ -66,28 +91,24 @@ def get_dst_transitions(
periods = [] periods = []
current = start_time 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 period_start = start_time
prev_offset = _utc_offset(tz, current)
# Check each day for offset changes # Probe at most a day ahead, capped at end_time so a transition after the
while current <= end_time: # last full day is still seen instead of silently kept in the last period.
dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC) while current < end_time:
local_dt = dt.astimezone(tz) next_probe = min(current + 86400, end_time)
current_offset = local_dt.utcoffset().total_seconds() next_offset = _utc_offset(tz, next_probe)
if current_offset != prev_offset: if next_offset != prev_offset:
# Found a transition - close previous period transition = _find_transition(tz, current, next_probe, prev_offset)
periods.append((period_start, current, prev_offset)) periods.append((period_start, transition, prev_offset))
period_start = current period_start = transition
prev_offset = current_offset 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)) periods.append((period_start, end_time, prev_offset))
return periods return periods
@@ -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);
});
});
+15 -4
View File
@@ -23,6 +23,9 @@
"overriddenGlobalHeading_one": "This camera overrides {{count}} field from the global config:", "overriddenGlobalHeading_one": "This camera overrides {{count}} field from the global config:",
"overriddenGlobalHeading_other": "This camera overrides {{count}} fields 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.", "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)", "overriddenBaseConfig": "Overridden (Base Config)",
"overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section", "overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section",
"overriddenBaseConfigHeading_one": "The {{profile}} profile overrides {{count}} field from the base config:", "overriddenBaseConfigHeading_one": "The {{profile}} profile overrides {{count}} field from the base config:",
@@ -1941,19 +1944,25 @@
"configMessages": { "configMessages": {
"review": { "review": {
"recordDisabled": "Recording is disabled, review items will not be generated.", "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.", "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.", "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": { "audio": {
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function." "noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
}, },
"audioTranscription": { "audioTranscription": {
"audioDetectionDisabled": "Audio detection is not enabled for this camera. Audio transcription requires audio detection to be active." "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": { "detect": {
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.", "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.", "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.", "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.", "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.", "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.",
@@ -1988,10 +1997,12 @@
"noRecordSubRole": "No streams have the record_sub role defined. Sub stream recording will not function." "noRecordSubRole": "No streams have the record_sub role defined. Sub stream recording will not function."
}, },
"birdseye": { "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": { "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": { "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." "jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
+1 -1
View File
@@ -133,7 +133,7 @@ export function MessageBubble({
variant="select" variant="select"
size="icon" size="icon"
className="size-9 rounded-full" className="size-9 rounded-full"
disabled={!draftContent.trim()} disabled={!draftContent.trim() || onEditSubmit == null}
onClick={handleEditSubmit} onClick={handleEditSubmit}
aria-label={t("send")} aria-label={t("send")}
> >
@@ -9,6 +9,11 @@ const audioTranscription: SectionConfigOverrides = {
health: (ctx) => health: (ctx) =>
ctx.fullCameraConfig?.audio_transcription?.enabled === true, ctx.fullCameraConfig?.audio_transcription?.enabled === true,
messageKey: "configMessages.audioTranscription.audioDetectionDisabled", messageKey: "configMessages.audioTranscription.audioDetectionDisabled",
runtimeOverride: {
section: "audio",
messageKey:
"configMessages.audioTranscription.audioDetectionRuntimeDisabled",
},
severity: "warning", severity: "warning",
condition: (ctx) => { condition: (ctx) => {
if (ctx.level === "camera" && ctx.fullCameraConfig) { if (ctx.level === "camera" && ctx.fullCameraConfig) {
@@ -7,6 +7,11 @@ const birdseye: SectionConfigOverrides = {
{ {
key: "object-tracking-detect-disabled", key: "object-tracking-detect-disabled",
messageKey: "configMessages.birdseye.objectTrackingDetectDisabled", messageKey: "configMessages.birdseye.objectTrackingDetectDisabled",
runtimeOverride: {
section: "detect",
messageKey:
"configMessages.birdseye.objectTrackingDetectRuntimeDisabled",
},
severity: "info", severity: "info",
condition: (ctx) => { condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false; if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
@@ -63,6 +63,10 @@ const objects: SectionConfigOverrides = {
{ {
key: "detect-disabled", key: "detect-disabled",
messageKey: "configMessages.detect.disabled", messageKey: "configMessages.detect.disabled",
runtimeOverride: {
section: "detect",
messageKey: "configMessages.detect.runtimeDisabled",
},
severity: "info", severity: "info",
condition: (ctx) => condition: (ctx) =>
ctx.level === "camera" && ctx.level === "camera" &&
@@ -7,6 +7,10 @@ const review: SectionConfigOverrides = {
{ {
key: "record-disabled", key: "record-disabled",
messageKey: "configMessages.review.recordDisabled", messageKey: "configMessages.review.recordDisabled",
runtimeOverride: {
section: "record",
messageKey: "configMessages.review.recordRuntimeDisabled",
},
severity: "warning", severity: "warning",
condition: (ctx) => { condition: (ctx) => {
if (ctx.level === "camera" && ctx.fullCameraConfig) { if (ctx.level === "camera" && ctx.fullCameraConfig) {
@@ -18,6 +22,10 @@ const review: SectionConfigOverrides = {
{ {
key: "detect-disabled", key: "detect-disabled",
messageKey: "configMessages.review.detectDisabled", messageKey: "configMessages.review.detectDisabled",
runtimeOverride: {
section: "detect",
messageKey: "configMessages.review.detectRuntimeDisabled",
},
severity: "info", severity: "info",
condition: (ctx) => { condition: (ctx) => {
if (ctx.level === "camera" && ctx.fullCameraConfig) { if (ctx.level === "camera" && ctx.fullCameraConfig) {
@@ -64,6 +72,11 @@ const review: SectionConfigOverrides = {
field: "genai.image_source", field: "genai.image_source",
messageKey: messageKey:
"configMessages.review.genaiImageSourceRecordingsRecordDisabled", "configMessages.review.genaiImageSourceRecordingsRecordDisabled",
runtimeOverride: {
section: "record",
messageKey:
"configMessages.review.genaiImageSourceRecordingsRecordRuntimeDisabled",
},
severity: "warning", severity: "warning",
position: "after", position: "after",
condition: (ctx) => { condition: (ctx) => {
@@ -7,6 +7,10 @@ const snapshots: SectionConfigOverrides = {
{ {
key: "detect-disabled", key: "detect-disabled",
messageKey: "configMessages.snapshots.detectDisabled", messageKey: "configMessages.snapshots.detectDisabled",
runtimeOverride: {
section: "detect",
messageKey: "configMessages.snapshots.detectRuntimeDisabled",
},
severity: "info", severity: "info",
condition: (ctx) => { condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false; if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
@@ -28,6 +28,17 @@ export type ConditionalMessage = {
values?: Record<string, unknown>; values?: Record<string, unknown>;
/** Optional documentation path (e.g. "/configuration/object_detectors#model"). */ /** Optional documentation path (e.g. "/configuration/object_detectors#model"). */
docLink?: string; 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. * Whether the Health tab evaluates this message against the saved config.
* Absent or false: form only. true: shown whenever condition() holds. A * Absent or false: form only. true: shown whenever condition() holds. A
@@ -1022,6 +1022,7 @@ export function ConfigSection({
formContext={{ formContext={{
level: effectiveLevel, level: effectiveLevel,
cameraName, cameraName,
sectionPath,
globalValue, globalValue,
cameraValue, cameraValue,
hasChanges, hasChanges,
@@ -19,6 +19,8 @@ import { LuExternalLink } from "react-icons/lu";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { requiresRestartForFieldPath } from "@/utils/configUtil"; import { requiresRestartForFieldPath } from "@/utils/configUtil";
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator"; import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
import RuntimeOverrideIndicator from "@/components/indicators/RuntimeOverrideIndicator";
import { getRuntimeOverride } from "@/utils/runtimeOverrides";
import { import {
buildTranslationPath, buildTranslationPath,
resolveConfigTranslation, resolveConfigTranslation,
@@ -211,6 +213,18 @@ export function FieldTemplate(props: FieldTemplateProps) {
defaultRequiresRestart, 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) // Use schema title/description as primary source (from JSON Schema)
const schemaTitle = schema.title; const schemaTitle = schema.title;
const schemaDescription = schema.description; const schemaDescription = schema.description;
@@ -502,6 +516,12 @@ export function FieldTemplate(props: FieldTemplateProps) {
{finalLabel} {finalLabel}
{required && <span className="ml-1 text-destructive">*</span>} {required && <span className="ml-1 text-destructive">*</span>}
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />} {fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
{runtimeOverride && (
<RuntimeOverrideIndicator
runtimeValue={runtimeOverride.runtime}
className="ml-2"
/>
)}
</Label> </Label>
); );
}; };
@@ -519,6 +539,12 @@ export function FieldTemplate(props: FieldTemplateProps) {
{finalLabel} {finalLabel}
{required && <span className="ml-1 text-destructive">*</span>} {required && <span className="ml-1 text-destructive">*</span>}
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />} {fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
{runtimeOverride && (
<RuntimeOverrideIndicator
runtimeValue={runtimeOverride.runtime}
className="ml-2"
/>
)}
</Label> </Label>
); );
}; };
@@ -540,6 +566,12 @@ export function FieldTemplate(props: FieldTemplateProps) {
{finalLabel} {finalLabel}
{required && <span className="ml-1 text-destructive">*</span>} {required && <span className="ml-1 text-destructive">*</span>}
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />} {fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
{runtimeOverride && (
<RuntimeOverrideIndicator
runtimeValue={runtimeOverride.runtime}
className="ml-2"
/>
)}
</Label> </Label>
); );
}; };
@@ -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>
);
}
+7 -2
View File
@@ -4,6 +4,7 @@ import type {
FieldConditionalMessage, FieldConditionalMessage,
MessageConditionContext, MessageConditionContext,
} from "@/components/config-form/section-configs/types"; } from "@/components/config-form/section-configs/types";
import { resolveMessageKey } from "@/utils/runtimeOverrides";
export function useConfigMessages( export function useConfigMessages(
messages: ConditionalMessage[] | undefined, messages: ConditionalMessage[] | undefined,
@@ -15,12 +16,16 @@ export function useConfigMessages(
} { } {
const activeMessages = useMemo(() => { const activeMessages = useMemo(() => {
if (!messages || !context) return []; 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]); }, [messages, context]);
const activeFieldMessages = useMemo(() => { const activeFieldMessages = useMemo(() => {
if (!fieldMessages || !context) return []; 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]); }, [fieldMessages, context]);
return { activeMessages, activeFieldMessages }; return { activeMessages, activeFieldMessages };
+18 -1
View File
@@ -7,6 +7,16 @@ export type KeyModifiers = {
shift: boolean; 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( export default function useKeyboardListener(
keys: string[], keys: string[],
listener?: (key: string | null, modifiers: KeyModifiers) => boolean, listener?: (key: string | null, modifiers: KeyModifiers) => boolean,
@@ -27,6 +37,10 @@ export default function useKeyboardListener(
return; return;
} }
if (handledElsewhere(e)) {
return;
}
const modifiers = { const modifiers = {
down: true, down: true,
repeat: e.repeat, repeat: e.repeat,
@@ -63,7 +77,10 @@ export default function useKeyboardListener(
} }
} else if (keys.includes(e.key) && listener) { } else if (keys.includes(e.key) && listener) {
const preventDefault = listener(e.key, modifiers); const preventDefault = listener(e.key, modifiers);
if (preventDefault) e.preventDefault(); if (preventDefault) {
e.preventDefault();
handledByShortcut.add(e);
}
} else if ( } else if (
listener && listener &&
(e.key === "Shift" || e.key === "Control" || e.key === "Meta") (e.key === "Shift" || e.key === "Control" || e.key === "Meta")
+1 -1
View File
@@ -362,7 +362,7 @@ export default function ChatPage() {
role="user" role="user"
content={msg.content} content={msg.content}
messageIndex={i} messageIndex={i}
onEditSubmit={handleEditSubmit} onEditSubmit={isLoading ? undefined : handleEditSubmit}
isComplete isComplete
showStats={showStats} showStats={showStats}
/> />
+2
View File
@@ -30,6 +30,8 @@ export type HiddenFieldEntry = string | ((ctx: HiddenFieldContext) => string[]);
export type ConfigFormContext = { export type ConfigFormContext = {
level?: "global" | "camera"; level?: "global" | "camera";
cameraName?: string; cameraName?: string;
/** Config section being edited, e.g. "audio" or "review". */
sectionPath?: string;
globalValue?: JsonValue; globalValue?: JsonValue;
cameraValue?: JsonValue; cameraValue?: JsonValue;
overrides?: JsonValue; overrides?: JsonValue;
+2 -1
View File
@@ -10,6 +10,7 @@ import type { FrigateConfig } from "@/types/frigateConfig";
import type { HealthProblem } from "@/types/health"; import type { HealthProblem } from "@/types/health";
import { getSectionConfig } from "@/utils/configUtil"; import { getSectionConfig } from "@/utils/configUtil";
import { activeCameras } from "@/utils/health"; import { activeCameras } from "@/utils/health";
import { resolveMessageKey } from "@/utils/runtimeOverrides";
function healthMessages( function healthMessages(
section: string, section: string,
@@ -47,7 +48,7 @@ function toProblem(
severity: message.severity, severity: message.severity,
scope, scope,
scopeIsCamera, scopeIsCamera,
text: t(message.messageKey, { text: t(resolveMessageKey(message, ctx), {
ns: "views/settings", ns: "views/settings",
...(message.values ?? {}), ...(message.values ?? {}),
}), }),
+12 -5
View File
@@ -13,6 +13,7 @@ import set from "lodash/set";
import { isJsonObject } from "@/lib/utils"; import { isJsonObject } from "@/lib/utils";
import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const"; import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const";
import { applySchemaDefaults } from "@/lib/config-schema"; import { applySchemaDefaults } from "@/lib/config-schema";
import { applyConfiguredToggles } from "@/utils/runtimeOverrides";
import { normalizeConfigValue } from "@/hooks/use-config-override"; import { normalizeConfigValue } from "@/hooks/use-config-override";
import { import {
modifySchemaForSection, 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 * Two things move the top-level (effective) value away from yaml. A profile
* section values. This helper returns that value when available, falling * merges its overrides into it, and the API then populates `base_config` with
* back to the top-level (effective) value otherwise. * 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( export function getBaseCameraSectionValue(
config: FrigateConfig | undefined, config: FrigateConfig | undefined,
@@ -118,7 +121,11 @@ export function getBaseCameraSectionValue(
const cam = config.cameras?.[cameraName]; const cam = config.cameras?.[cameraName];
if (!cam) return undefined; if (!cam) return undefined;
const base = cam.base_config?.[sectionPath]; 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 // mergeWith customizer that replaces arrays wholesale instead of merging them
+148
View File
@@ -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; onRefresh: () => void;
onDelete: (ids: string[]) => 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({ function TrainGrid({
model, model,
contentRef, contentRef,
@@ -936,7 +944,10 @@ function TrainGrid({
return true; return true;
} }
if (trainFilter.classes && !trainFilter.classes.includes(data.name)) { if (
trainFilter.classes &&
!matchesTrainClass(trainFilter.classes, data.name)
) {
return false; return false;
} }