Compare commits

...
Author SHA1 Message Date
Josh HawkinsandGitHub 0c52a3175d update radix, react, konva and other web dependencies (#24354)
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
konva 10.5 removed the private `Node._lastPos`, so `PolygonCanvas` now reads the dragged point from `getAbsolutePosition()`, which returns the position konva just applied. monaco-yaml 5.5 takes formatter options instead of a boolean for `format`. The radix packages move together so every shared primitive stays a single copy under the `react-slot` and `compose-refs` overrides.
2026-09-15 12:39:31 -05:00
Josh HawkinsandGitHub 4c648f8147 bump copy-to-clipboard to 4.0.2 (#24353) 2026-09-15 12:03:20 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e0d4337a25 Update memray requirement from ==1.15.* to ==1.20.* in /docker/main (#24343)
Updates the requirements on [memray](https://github.com/bloomberg/memray) to permit the latest version.
- [Release notes](https://github.com/bloomberg/memray/releases)
- [Changelog](https://github.com/bloomberg/memray/blob/main/NEWS.rst)
- [Commits](https://github.com/bloomberg/memray/compare/v1.15.0...v1.20.0)

---
updated-dependencies:
- dependency-name: memray
  dependency-version: 1.20.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:16:45 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5e689f2d85 Update uvicorn requirement from ==0.46.* to ==0.52.* in /docker/main (#24341)
Updates the requirements on [uvicorn](https://github.com/Kludex/uvicorn) to permit the latest version.
- [Release notes](https://github.com/Kludex/uvicorn/releases)
- [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/uvicorn/compare/0.46.0...0.52.4)

---
updated-dependencies:
- dependency-name: uvicorn
  dependency-version: 0.52.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:16:40 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
57a2765d00 Update netaddr requirement from ==0.8.* to ==1.3.* in /docker/main (#24346)
Updates the requirements on [netaddr](https://github.com/netaddr/netaddr) to permit the latest version.
- [Release notes](https://github.com/netaddr/netaddr/releases)
- [Changelog](https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/netaddr/netaddr/compare/0.8.0...1.3.0)

---
updated-dependencies:
- dependency-name: netaddr
  dependency-version: 1.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:16:33 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
dd77bae4f7 Bump @radix-ui/react-progress from 1.1.8 to 1.1.16 in /web (#24342)
Bumps [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) from 1.1.8 to 1.1.16.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-progress"
  dependency-version: 1.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:16:16 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8a98d7c9b1 Bump @radix-ui/react-radio-group from 1.3.8 to 1.4.7 in /web (#24339)
Bumps [@radix-ui/react-radio-group](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radio-group) from 1.3.8 to 1.4.7.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radio-group/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radio-group)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-radio-group"
  dependency-version: 1.4.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:00:33 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8a8da663c0 Bump lucide-react from 0.577.0 to 1.45.0 in /web (#24337)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.577.0 to 1.45.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.45.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:00:26 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
eddc9fccd1 Bump autoprefixer from 10.4.20 to 10.5.6 in /web (#24340)
Bumps [autoprefixer](https://github.com/postcss/autoprefixer) from 10.4.20 to 10.5.6.
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.4.20...10.5.6)

---
updated-dependencies:
- dependency-name: autoprefixer
  dependency-version: 10.5.6
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:00:19 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
84f981a77c Bump @radix-ui/react-toggle from 1.1.10 to 1.1.18 in /web (#24344)
Bumps [@radix-ui/react-toggle](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/toggle) from 1.1.10 to 1.1.18.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/toggle/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/toggle)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-toggle"
  dependency-version: 1.1.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:00:12 -05:00
Josh HawkinsandGitHub 4100383738 Miscellaneous fixes (#24352)
* fix recordings unavailable endpoint when no params are provided

we already import the datetime class directly, so those attribute lookups raised AttributeError and the request returned 500

* reject JWTs whose role is no longer in the config

`/auth` trusted the role inside the JWT and re-signed it on refresh without checking the config, so a user whose restricted role was deleted kept a session carrying a role that isn't in `auth.roles`. The media, clip, recording, export and go2rtc checks treat a missing role the same as a role with no camera list, so that session could open every camera. `/auth` now returns 401 for a token whose role isn't configured, which sends the user back through login, and login already falls back to `viewer` for a role that's gone.

* delete the deleted camera group's layout, not the open one's

Deleting a camera group removed the layout of the group being viewed, because the dialog's delete was bound to `${activeGroup}-draggable-layout`. It also cleared the saved group, and both ran before the `config/set` request whether or not it succeeded, so deleting one group while viewing another lost the open group's layout and left the deleted group's layout behind. The deleted group's own layout is now removed after a successful save, and the saved group is only cleared when it was the open group.

* don't block API when querying PTZ info

camera_ptz_info is async but waited on the ONVIF controller's future with future.result(), blocking the API event loop for as long as a slow or unreachable ONVIF camera took to answer (including reconnect attempts), so every other async request stalled with it. Await the future with asyncio.wrap_future instead. The coroutine runs on the controller's own loop and thread, so this cannot deadlock.

* for custom exports, only allow admin users to add to existing cases

follows the existing convention where attaching an export to an existing case is admin-only on `POST /export/{camera}/...` and `POST /exports/batch`

* drop pending edits for a camera or profile that no longer exists

* match cached preview frames to their camera exactly

https://github.com/blakeblackshear/frigate/pull/22594 added a trailing `-` to the `preview_{camera}` prefix so `camera` stopped matching `camera2`'s frames, but camera names can contain `-`, so `front` still matched `front-door`'s. After a restart, `front`'s preview recorder deleted this hour's frames of a matching camera that sorted before it and added the timestamps of one that sorted after it, so ffmpeg was asked for files that don't exist and that hour's preview was lost. The offline fallback for `latest.jpg` could also return `front-door`'s frame for `front`, even to a user without access to `front-door`, and a short export could take its fallback thumbnail from the other camera. These now compare the full camera name taken from the file name.
2026-09-15 07:48:30 -05:00
Josh HawkinsandGitHub fa30a7e1ae Replace react-logviewer with virtua (#24334)
* bump react-logviewer to 6.5.5

* use virtua for UI logs
2026-09-15 05:44:04 -06:00
20 changed files with 973 additions and 3027 deletions
+3 -3
View File
@@ -5,7 +5,7 @@ aiohttp == 3.12.*
starlette == 0.47.*
starlette-context == 0.5.*
fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
uvicorn == 0.46.*
uvicorn == 0.52.*
slowapi == 0.1.*
joserfc == 1.6.*
cryptography == 46.0.*
@@ -61,7 +61,7 @@ rapidfuzz==3.12.*
argcomplete==2.0.*
contextlib2==0.6.*
future==0.18.*
netaddr==0.8.*
netaddr==1.3.*
netifaces==0.10.*
prometheus-client == 0.26.*
# TFLite
@@ -73,4 +73,4 @@ faster-whisper==1.2.*
librosa==0.11.*
soundfile==0.13.*
# Memory profiling
memray == 1.15.*
memray == 1.20.*
+7
View File
@@ -786,6 +786,13 @@ def auth(request: Request):
user = token.claims.get("sub")
role = token.claims.get("role")
# the token keeps the role it was issued with, so a role removed from
# the config since then must send the user back through login
if role not in auth_config.roles:
logger.debug("jwt role %s is not in the config", role)
return fail_response
current_time = int(time.time())
# if the jwt is expired
+14 -4
View File
@@ -1038,6 +1038,10 @@ def export_recording_custom(
if camera_validation_error is not None:
return camera_validation_error
# Validate user-provided ffmpeg args to prevent injection and add to cases.
# Admin users are trusted and skip validation.
is_admin = request.headers.get("remote-role", "") == "admin"
playback_source = body.source
friendly_name = body.name
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
@@ -1048,6 +1052,16 @@ def export_recording_custom(
cpu_fallback = body.cpu_fallback
export_case_id = body.export_case_id
if export_case_id is not None and not is_admin:
return JSONResponse(
content={
"success": False,
"message": "Only admins can attach exports to an existing case.",
},
status_code=403,
)
case_validation_error = _validate_export_case(export_case_id)
if case_validation_error is not None:
return case_validation_error
@@ -1064,10 +1078,6 @@ def export_recording_custom(
status_code=400,
)
# Validate user-provided ffmpeg args to prevent injection.
# Admin users are trusted and skip validation.
is_admin = request.headers.get("remote-role", "") == "admin"
if not is_admin:
for args_label, args_value in [
("input", ffmpeg_input_args),
+1 -1
View File
@@ -189,7 +189,7 @@ async def camera_ptz_info(request: Request, camera_name: str):
future = asyncio.run_coroutine_threadsafe(
request.app.onvif.get_camera_info(camera_name), request.app.onvif.loop
)
result = future.result()
result = await asyncio.wrap_future(future)
return JSONResponse(content=result)
else:
return JSONResponse(
+2 -5
View File
@@ -412,11 +412,8 @@ async def no_recordings(
if not camera_list:
return JSONResponse(content=[])
before = params.before or datetime.datetime.now().timestamp()
after = (
params.after
or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp()
)
before = params.before or datetime.now().timestamp()
after = params.after or (datetime.now() - timedelta(hours=1)).timestamp()
scale = params.scale
recordings: list[tuple[float, float]] = []
+8 -2
View File
@@ -66,6 +66,12 @@ def get_cache_image_name(camera: str, frame_time: float) -> str:
)
def is_camera_preview_frame(file_name: str, camera: str) -> bool:
"""Check whether a cached preview frame file belongs to the camera."""
# camera names may contain "-", so a prefix match would include "front-door"
return file_name.rsplit("-", 1)[0] == f"preview_{camera}"
def get_most_recent_preview_frame(
camera: str, before: float | None = None
) -> str | None:
@@ -79,7 +85,7 @@ def get_most_recent_preview_frame(
preview_files = [
f
for f in os.listdir(PREVIEW_CACHE_DIR)
if f.startswith(f"preview_{camera}-")
if is_camera_preview_frame(f, camera)
and f.endswith(f".{PREVIEW_FRAME_TYPE}")
]
@@ -276,7 +282,7 @@ class PreviewRecorder:
start_file = f"{file_start}{start_ts}.webp"
for file in sorted(os.listdir(os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES))):
if not file.startswith(file_start):
if not is_camera_preview_frame(file, self.camera_name):
continue
if file < start_file:
+2 -1
View File
@@ -36,6 +36,7 @@ from frigate.ffmpeg_presets import (
parse_preset_hardware_acceleration_encode,
)
from frigate.models import Export, Previews, Recordings, ReviewSegment
from frigate.output.preview import is_camera_preview_frame
from frigate.util.ffmpeg import run_ffmpeg_with_progress
from frigate.util.ownership import chown_to_runtime
from frigate.util.recording_coverage import (
@@ -1098,7 +1099,7 @@ class RecordingExporter(threading.Thread):
fallback_preview = None
for file in sorted(os.listdir(preview_dir)):
if not file.startswith(file_start):
if not is_camera_preview_frame(file, self.camera):
continue
if file < start_file:
@@ -0,0 +1,81 @@
"""Tests that /auth only accepts a JWT whose role is still in the config."""
import os
import time
from unittest.mock import MagicMock, Mock, patch
from frigate.api.auth import create_encoded_jwt
from frigate.api.fastapi_app import create_fastapi_app
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.const import JWT_SECRET_ENV_VAR
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
class TestAuthJwtRole(BaseTestHttp):
def setUp(self):
super().setUp(models=[Event, Recordings, ReviewSegment])
self.minimal_config = {
"mqtt": {"host": "mqtt"},
"auth": {"enabled": True, "roles": {"garage": ["front_door"]}},
"networking": {"listen": {"internal": 5000, "external": 8971}},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
def _create_app(self):
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
mock_publisher.publisher = MagicMock()
return create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
mock_publisher,
None,
enforce_default_admin=False,
)
def _auth(self, app, role: str):
token = create_encoded_jwt("bob", role, int(time.time()) + 3600, app.jwt_token)
with AuthTestClient(app) as client:
return client.get(
"/auth",
headers={
"x-server-port": "8971",
"authorization": f"Bearer {token}",
},
)
def test_configured_role_is_accepted(self):
resp = self._auth(self._create_app(), "garage")
self.assertEqual(resp.status_code, 202)
self.assertEqual(resp.headers["remote-user"], "bob")
self.assertEqual(resp.headers["remote-role"], "garage")
def test_role_removed_from_config_is_rejected(self):
resp = self._auth(self._create_app(), "removed_role")
self.assertEqual(resp.status_code, 401)
self.assertNotIn("remote-role", resp.headers)
+15
View File
@@ -75,6 +75,21 @@ class TestPreviewLoader(unittest.TestCase):
self.assertIsNone(get_most_recent_preview_frame(camera))
def test_get_most_recent_preview_frame_hyphenated_camera(self):
for name in ("preview_front-2000.0", "preview_front-door-3000.0"):
with open(
os.path.join(PREVIEW_CACHE_DIR, f"{name}.{PREVIEW_FRAME_TYPE}"), "w"
) as f:
f.write("test")
expected_path = os.path.join(
PREVIEW_CACHE_DIR, f"preview_front-2000.0.{PREVIEW_FRAME_TYPE}"
)
self.assertEqual(get_most_recent_preview_frame("front"), expected_path)
self.assertEqual(
get_most_recent_preview_frame("front", before=5000.0), expected_path
)
def test_get_most_recent_preview_frame_no_directory(self):
shutil.rmtree(PREVIEW_CACHE_DIR)
self.assertIsNone(get_most_recent_preview_frame("test_camera"))
+548 -2679
View File
File diff suppressed because it is too large Load Diff
+45 -45
View File
@@ -25,102 +25,102 @@
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.2",
"@hookform/resolvers": "^3.10.0",
"@melloware/react-logviewer": "^6.1.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-aspect-ratio": "^1.1.15",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-context-menu": "^2.3.7",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-hover-card": "^1.1.23",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-radio-group": "^1.4.7",
"@radix-ui/react-scroll-area": "^1.2.18",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slider": "^1.4.7",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-toggle": "^1.1.18",
"@radix-ui/react-toggle-group": "^1.1.19",
"@radix-ui/react-tooltip": "^1.2.16",
"@rjsf/core": "^6.10.0",
"@rjsf/shadcn": "^6.10.0",
"@rjsf/utils": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"apexcharts": "^7.3.0",
"axios": "^1.18.0",
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"copy-to-clipboard": "^3.3.3",
"copy-to-clipboard": "^4.0.2",
"date-fns": "^3.6.0",
"date-fns-tz": "^3.2.0",
"framer-motion": "^13.2.0",
"hls.js": "^1.6.15",
"framer-motion": "^13.3.0",
"hls.js": "^1.7.3",
"i18next": "^24.2.0",
"i18next-http-backend": "^4.0.2",
"idb-keyval": "^6.2.1",
"idb-keyval": "^6.3.0",
"js-yaml": "^4.3.2",
"konva": "^10.2.3",
"konva": "^10.5.0",
"lodash": "^4.18.1",
"lucide-react": "^0.577.0",
"monaco-yaml": "^5.4.1",
"lucide-react": "^1.46.0",
"monaco-yaml": "^5.5.1",
"next-themes": "^0.4.6",
"nosleep.js": "^0.12.0",
"react": "^19.2.4",
"react": "^19.3.0",
"react-apexcharts": "^2.1.1",
"react-day-picker": "^9.14.0",
"react-device-detect": "^2.2.3",
"react-dom": "^19.2.4",
"react-dom": "^19.3.0",
"react-dropzone": "^14.3.8",
"react-grid-layout": "^2.2.2",
"react-hook-form": "^7.72.0",
"react-grid-layout": "^2.2.4",
"react-hook-form": "^7.88.0",
"react-i18next": "^15.2.0",
"react-icons": "^5.6.0",
"react-konva": "^19.2.3",
"react-icons": "^5.7.0",
"react-konva": "^19.2.7",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.30.6",
"react-swipeable": "^7.0.2",
"react-zoom-pan-pinch": "3.4.4",
"remark-gfm": "^4.0.0",
"scroll-into-view-if-needed": "^3.1.0",
"sonner": "^2.0.7",
"sonner": "^2.0.8",
"sort-by": "^1.2.0",
"strftime": "^0.10.3",
"swr": "^2.4.1",
"swr": "^2.5.1",
"tailwind-merge": "^2.4.0",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss-animate": "^1.0.7",
"use-long-press": "^3.2.0",
"use-long-press": "^3.3.0",
"vaul": "^1.1.2",
"virtua": "^0.51.3",
"vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.59.1",
"@tailwindcss/forms": "^0.5.9",
"@playwright/test": "^1.63.0",
"@tailwindcss/forms": "^0.5.11",
"@testing-library/jest-dom": "^6.6.2",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.12",
"@types/lodash": "^4.17.25",
"@types/node": "^25.9.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/strftime": "^0.9.8",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^4.1.11",
"autoprefixer": "^10.4.20",
"autoprefixer": "^10.6.0",
"esbuild": "^0.28.2",
"eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.6",
"eslint-plugin-react-refresh": "^0.5.7",
"fake-indexeddb": "^6.0.0",
"globals": "^17.12.0",
"i18next-cli": "^1.5.11",
+5 -3
View File
@@ -92,10 +92,12 @@ function PagePanel({ failure }: PanelProps) {
const stale = isStaleAsset(failure);
const onCopy = () => {
// copy() falls back to a prompt and returns false when the clipboard is
const onCopy = async () => {
// copy() falls back to a prompt and resolves false when the clipboard is
// refused, so a success toast has to wait on the result.
const copied = copy(crashReport(failure, stats?.service.version));
const copied = await copy(crashReport(failure, stats?.service.version), {
fallbackToPrompt: true,
});
if (copied) {
toast.success(t("button.copiedToClipboard"), { position: "top-center" });
+2 -2
View File
@@ -71,10 +71,10 @@ export function MessageBubble({
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
const handleCopy = useCallback(async () => {
const text = content?.trim() || "";
if (!text) return;
if (copy(text)) {
if (await copy(text)) {
setCopied(true);
toast.success(t("button.copiedToClipboard", { ns: "common" }));
setTimeout(() => setCopied(false), 2000);
@@ -1,91 +0,0 @@
import { useRef, useCallback, useEffect, type ReactNode } from "react";
import { ScrollFollow } from "@melloware/react-logviewer";
export type ScrollFollowProps = {
startFollowing?: boolean;
render: (renderProps: ScrollFollowRenderProps) => ReactNode;
onCustomScroll?: (
scrollTop: number,
scrollHeight: number,
clientHeight: number,
) => void;
};
export type ScrollFollowRenderProps = {
follow: boolean;
onScroll: (args: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}) => void;
startFollowing: () => void;
stopFollowing: () => void;
onCustomScroll?: (
scrollTop: number,
scrollHeight: number,
clientHeight: number,
) => void;
};
const SCROLL_BUFFER = 5;
export default function EnhancedScrollFollow(props: ScrollFollowProps) {
const followRef = useRef(props.startFollowing || false);
const prevScrollTopRef = useRef<number | undefined>(undefined);
useEffect(() => {
prevScrollTopRef.current = undefined;
}, []);
const wrappedRender = useCallback(
(renderProps: ScrollFollowRenderProps) => {
const wrappedOnScroll = (args: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}) => {
// Check if scrolling up and immediately stop following
if (
prevScrollTopRef.current !== undefined &&
args.scrollTop < prevScrollTopRef.current
) {
if (followRef.current) {
renderProps.stopFollowing();
followRef.current = false;
}
}
const bottomThreshold =
args.scrollHeight - args.clientHeight - SCROLL_BUFFER;
const isNearBottom = args.scrollTop >= bottomThreshold;
if (isNearBottom && !followRef.current) {
renderProps.startFollowing();
followRef.current = true;
} else if (!isNearBottom && followRef.current) {
renderProps.stopFollowing();
followRef.current = false;
}
prevScrollTopRef.current = args.scrollTop;
renderProps.onScroll(args);
if (props.onCustomScroll) {
props.onCustomScroll(
args.scrollTop,
args.scrollHeight,
args.clientHeight,
);
}
};
return props.render({
...renderProps,
onScroll: wrappedOnScroll,
follow: followRef.current,
});
},
[props],
);
return <ScrollFollow {...props} render={wrappedRender} />;
}
@@ -10,6 +10,7 @@ import { MdHome } from "react-icons/md";
import { Button, buttonVariants } from "../ui/button";
import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
@@ -66,7 +67,8 @@ import { z } from "zod";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { deleteUserNamespacedKey } from "@/hooks/use-user-persistence";
import { AuthContext } from "@/context/auth-context";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
import * as LuIcons from "react-icons/lu";
@@ -499,9 +501,8 @@ function NewGroupDialog({
const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
const [isLoading, setIsLoading] = useState(false);
const [, , , deleteGridLayout] = useUserPersistence(
`${activeGroup}-draggable-layout`,
);
const { auth } = useContext(AuthContext);
const username = auth?.user?.username;
useEffect(() => {
if (!open) {
@@ -513,15 +514,14 @@ function NewGroupDialog({
const onDeleteGroup = useCallback(
async (name: string) => {
deleteGridLayout();
deleteGroup();
await axios
.put(`config/set?camera_groups.${name}`, { requires_restart: 0 })
.then((res) => {
if (res.status === 200) {
deleteUserNamespacedKey(`${name}-draggable-layout`, username);
if (activeGroup == name) {
// deleting current group
deleteGroup();
setGroup("default");
}
updateConfig();
@@ -557,15 +557,7 @@ function NewGroupDialog({
setIsLoading(false);
});
},
[
updateConfig,
activeGroup,
setGroup,
setOpen,
deleteGroup,
deleteGridLayout,
t,
],
[updateConfig, activeGroup, setGroup, setOpen, deleteGroup, username, t],
);
const onSave = () => {
@@ -203,7 +203,8 @@ export function PolygonCanvas({
if (stage) {
// we add an unfilled line for adding points when finished
const index = e.target.index - (activePolygon.isFinished ? 2 : 1);
let pos = [e.target._lastPos!.x, e.target._lastPos!.y];
const dragged = e.target.getAbsolutePosition();
let pos = [dragged.x, dragged.y];
if (snapPoints) {
// Snap to other polygons' edges
-5
View File
@@ -184,8 +184,3 @@ html {
.react-grid-layout .react-grid-item {
transition: none !important;
}
.react-lazylog,
.react-lazylog-searchbar {
background-color: transparent !important;
}
+1 -1
View File
@@ -123,7 +123,7 @@ function ConfigEditor() {
hover: true,
completion: true,
validate: true,
format: true,
format: { enable: true },
schemas: [
{
uri: `${apiHost}api/config/schema.json`,
+200 -168
View File
@@ -8,7 +8,13 @@ import {
logTypes,
} from "@/types/log";
import copy from "copy-to-clipboard";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import axios from "axios";
import LogInfoDialog from "@/components/overlay/LogInfoDialog";
import { LogChip } from "@/components/indicators/Chip";
@@ -21,34 +27,110 @@ import { cn } from "@/lib/utils";
import { parseLogLines } from "@/utils/logUtil";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import scrollIntoView from "scroll-into-view-if-needed";
import { LazyLog } from "@melloware/react-logviewer";
import { VList, type VListHandle } from "virtua";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import EnhancedScrollFollow from "@/components/dynamic/EnhancedScrollFollow";
import { MdCircle } from "react-icons/md";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { debounce } from "lodash";
import { isIOS, isMobile } from "react-device-detect";
import { isDesktop, isIOS, isMobile } from "react-device-detect";
import { isPWA } from "@/utils/isPWA";
import { isInIframe } from "@/utils/isIFrame";
import { useTranslation } from "react-i18next";
import WsMessageFeed from "@/components/ws/WsMessageFeed";
const OLDER_LINES_CHUNK_SIZE = 100;
const FOLLOW_THRESHOLD_PX = 40;
// Desktop row height. Without it, virtua guesses 40px and the first render
// leaves the viewport partly empty. Mobile rows are taller, and a low hint
// there shrinks the scroll room iOS gets while it defers scroll correction
const ROW_HEIGHT_HINT_PX = 29;
// Stable ids keep row measurements attached to the right line after a prepend
type LogEntry = { id: number; text: string };
// shift anchors the viewport to the end when lines are prepended. stick keeps
// the newest line in view when lines are appended
type LogState = { entries: LogEntry[]; shift: boolean; stick: boolean };
function Logs() {
const { t } = useTranslation(["views/system"]);
const [logService, setLogService] = useState<LogType>("frigate");
const isWebsocket = logService === "websocket";
const tabsRef = useRef<HTMLDivElement | null>(null);
const lazyLogWrapperRef = useRef<HTMLDivElement>(null);
const [logs, setLogs] = useState<string[]>([]);
const logWrapperRef = useRef<HTMLDivElement>(null);
const listRef = useRef<VListHandle>(null);
const [logState, setLogState] = useState<LogState>({
entries: [],
shift: false,
stick: true,
});
const [filterSeverity, setFilterSeverity] = useState<LogSeverity[]>();
const [selectedLog, setSelectedLog] = useState<LogLine>();
const lazyLogRef = useRef<LazyLog>(null);
const [isLoading, setIsLoading] = useState(true);
const [follow, setFollow] = useState(true);
const lastFetchedIndexRef = useRef(-1);
const loadingOlderRef = useRef(false);
const firstIdRef = useRef(0);
const lastIdRef = useRef(0);
// The last wheel or key scroll went up. The view can still sit at the
// bottom for a moment, so position alone would keep following
const scrolledUpRef = useRef(false);
// lines
const isFollowing = useCallback(() => {
const list = listRef.current;
if (!list || scrolledUpRef.current) return false;
return (
list.scrollSize - list.scrollOffset - list.viewportSize <
FOLLOW_THRESHOLD_PX
);
}, []);
const resetLines = useCallback((lines: string[]) => {
firstIdRef.current = 0;
lastIdRef.current = lines.length;
setLogState({
entries: lines.map((text, id) => ({ id, text })),
shift: false,
stick: true,
});
}, []);
const appendLines = useCallback(
(lines: string[]) => {
const entries = lines
.filter((text) => text.trim())
.map((text) => ({ id: lastIdRef.current++, text }));
if (!entries.length) return;
const stick = isFollowing();
setLogState((prev) => ({
entries: [...prev.entries, ...entries],
shift: false,
stick,
}));
},
[isFollowing],
);
const prependLines = useCallback((lines: string[]) => {
firstIdRef.current -= lines.length;
const firstId = firstIdRef.current;
const entries = lines.map((text, i) => ({ id: firstId + i, text }));
setLogState((prev) => ({
entries: [...entries, ...prev.entries],
shift: true,
stick: false,
}));
}, []);
useEffect(() => {
document.title = t("documentTitle.logs." + logService);
@@ -102,8 +184,7 @@ function Logs() {
response.data &&
Array.isArray(response.data.lines)
) {
const filteredLines = filterLines(response.data.lines);
return filteredLines;
return response.data.lines as string[];
}
} catch (error) {
const errorMessage =
@@ -115,26 +196,27 @@ function Logs() {
},
);
}
return [];
return null;
},
[logService, filterLines, t],
[logService, t],
);
const fetchInitialLogs = useCallback(async () => {
setIsLoading(true);
try {
const response = await axios.get(`logs/${logService}`, {
params: { start: filterSeverity ? 0 : -100 },
params: { start: filterSeverity?.length ? 0 : -100 },
});
if (
response.status === 200 &&
response.data &&
Array.isArray(response.data.lines)
) {
const filteredLines = filterLines(response.data.lines);
setLogs(filteredLines);
resetLines(filterLines(response.data.lines));
// A filtered load fetches the whole file, so nothing older remains
lastFetchedIndexRef.current =
response.data.totalLines - filteredLines.length;
response.data.totalLines - response.data.lines.length;
}
} catch (error) {
const errorMessage =
@@ -145,7 +227,7 @@ function Logs() {
} finally {
setIsLoading(false);
}
}, [logService, filterLines, filterSeverity, t]);
}, [logService, filterLines, filterSeverity, resetLines, t]);
const abortControllerRef = useRef<AbortController | null>(null);
@@ -180,9 +262,7 @@ function Logs() {
return filterSeverity.includes(parsedLine.severity);
})
: lines;
if (filteredLines.length > 0) {
lazyLogRef.current?.appendLines(filteredLines);
}
appendLines(filteredLines);
}
// Process next chunk
return processStreamChunk(reader);
@@ -215,17 +295,19 @@ function Logs() {
);
}
});
}, [logService, filterSeverity, t]);
}, [logService, filterSeverity, appendLines, t]);
useEffect(() => {
if (isWebsocket) {
setIsLoading(false);
setLogs([]);
resetLines([]);
return;
}
setIsLoading(true);
setLogs([]);
setFollow(true);
scrolledUpRef.current = false;
resetLines([]);
lastFetchedIndexRef.current = -1;
fetchInitialLogs().then(() => {
// Start streaming after initial load
@@ -243,70 +325,52 @@ function Logs() {
// handlers
const prependLines = useCallback((newLines: string[]) => {
if (!lazyLogRef.current) return;
const loadOlderLines = useCallback(async () => {
const end = lastFetchedIndexRef.current;
if (loadingOlderRef.current || end <= 0) return;
const newLinesArray = newLines.map(
(line) => new Uint8Array(new TextEncoder().encode(line + "\n")),
);
loadingOlderRef.current = true;
const start = Math.max(0, end - OLDER_LINES_CHUNK_SIZE);
const lines = await fetchLogRange(start, end);
loadingOlderRef.current = false;
lazyLogRef.current.setState((prevState) => ({
...prevState,
lines: prevState.lines.unshift(...newLinesArray),
count: prevState.count + newLines.length,
}));
}, []);
// A service or filter change resets the index while the request is in flight
if (!lines || lastFetchedIndexRef.current !== end) return;
// debounced
const handleScroll = useMemo(
() =>
debounce(() => {
const scrollThreshold =
lazyLogRef.current?.listRef.current?.findEndIndex() ?? 10;
const startIndex =
lazyLogRef.current?.listRef.current?.findStartIndex() ?? 0;
const endIndex =
lazyLogRef.current?.listRef.current?.findEndIndex() ?? 0;
const pageSize = endIndex - startIndex;
if (
scrollThreshold < pageSize + pageSize / 2 &&
lastFetchedIndexRef.current > 0 &&
!isLoading
) {
const nextEnd = lastFetchedIndexRef.current;
const nextStart = Math.max(0, nextEnd - (pageSize || 100));
setIsLoading(true);
lastFetchedIndexRef.current = start;
prependLines(lines);
}, [fetchLogRange, prependLines]);
fetchLogRange(nextStart, nextEnd).then((newLines) => {
if (newLines.length > 0) {
prependLines(newLines);
lastFetchedIndexRef.current = nextStart;
// Runs on scroll events and on wheel or key input, because input at the top
// or bottom edge doesn't scroll and fires no scroll event
const syncScrollState = useCallback(() => {
const list = listRef.current;
if (!list) return;
lazyLogRef.current?.listRef.current?.scrollTo(
newLines.length *
lazyLogRef.current?.listRef.current?.getItemSize(1),
);
}
});
setFollow(isFollowing());
setIsLoading(false);
}
}, 50),
[fetchLogRange, isLoading, prependLines],
);
const handleCopyLogs = useCallback(() => {
if (logs.length) {
fetchInitialLogs()
.then(() => {
copy(logs.join("\n"));
toast.success(t("logs.copy.success"));
})
.catch(() => {
toast.error(t("logs.copy.error"));
});
if (list.scrollOffset < list.viewportSize) {
loadOlderLines();
}
}, [logs, fetchInitialLogs, t]);
}, [isFollowing, loadOlderLines]);
useLayoutEffect(() => {
if (isLoading || !logState.stick || !logState.entries.length) return;
listRef.current?.scrollToIndex(logState.entries.length - 1, {
align: "end",
});
}, [isLoading, logState]);
const handleCopyLogs = useCallback(async () => {
if (!logState.entries.length) return;
if (await copy(logState.entries.map((entry) => entry.text).join("\n"))) {
toast.success(t("logs.copy.success"));
} else {
toast.error(t("logs.copy.error"));
}
}, [logState, t]);
const handleDownloadLogs = useCallback(() => {
axios
@@ -329,77 +393,34 @@ function Logs() {
.catch(() => {});
}, [logService]);
const handleRowClick = useCallback(
(rowInfo: { lineNumber: number; rowIndex: number }) => {
const clickedLine = parseLogLines(logService, [
logs[rowInfo.rowIndex],
])[0];
setSelectedLog(clickedLine);
},
[logs, logService],
);
// keyboard listener
useKeyboardListener(
["PageDown", "PageUp", "ArrowDown", "ArrowUp"],
(key, modifiers) => {
if (!key || !modifiers.down || !lazyLogWrapperRef.current) {
const list = listRef.current;
if (!key || !modifiers.down || !list) {
return true;
}
const container =
lazyLogWrapperRef.current.querySelector(".react-lazylog");
const logLineHeight = container?.querySelector(".log-line")?.clientHeight;
if (!logLineHeight) {
const rowHeight = list.getItemSize(list.findItemIndex(list.scrollOffset));
if (!rowHeight) {
return true;
}
const scrollAmount = key.includes("Page")
? logLineHeight * 10
: logLineHeight;
const rows = key.includes("Page") ? 10 : 1;
const direction = key.includes("Down") ? 1 : -1;
container?.scrollBy({ top: scrollAmount * direction });
scrolledUpRef.current = direction < 0;
list.scrollBy(rowHeight * rows * direction);
syncScrollState();
return true;
},
);
// format lines
const lineBufferRef = useRef<string>("");
const formatPart = useCallback(
(text: string) => {
lineBufferRef.current += text;
if (text.endsWith("\n")) {
const completeLine = lineBufferRef.current.trim();
lineBufferRef.current = "";
if (completeLine) {
const parsedLine = parseLogLines(logService, [completeLine])[0];
return (
<LogLineData
line={parsedLine}
logService={logService}
onClickSeverity={() => setFilterSeverity([parsedLine.severity])}
onSelect={() => setSelectedLog(parsedLine)}
/>
);
}
}
return null;
},
[logService, setFilterSeverity, setSelectedLog],
);
useEffect(() => {
const handleCopy = (e: ClipboardEvent) => {
e.preventDefault();
if (!lazyLogWrapperRef.current) return;
if (!logWrapperRef.current) return;
const selection = window.getSelection();
if (!selection) return;
@@ -467,7 +488,7 @@ function Logs() {
e.clipboardData?.setData("text/plain", copyText);
};
const content = lazyLogWrapperRef.current;
const content = logWrapperRef.current;
content?.addEventListener("copy", handleCopy);
return () => {
content?.removeEventListener("copy", handleCopy);
@@ -488,7 +509,6 @@ function Logs() {
value={logService}
onValueChange={(value: LogType) => {
if (value) {
setLogs([]);
setFilterSeverity(undefined);
setLogService(value);
}
@@ -582,44 +602,56 @@ function Logs() {
</div>
</div>
<div ref={lazyLogWrapperRef} className="size-full">
<div
ref={logWrapperRef}
className="min-h-0 flex-1"
onPointerDown={() => {
scrolledUpRef.current = false;
}}
>
{isLoading ? (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
) : (
<EnhancedScrollFollow
startFollowing={!isLoading}
onCustomScroll={handleScroll}
render={({ follow, onScroll }) => (
<>
{follow && !logSettings.disableStreaming && (
<div className="absolute right-1 top-3">
<Tooltip>
<TooltipTrigger>
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
</TooltipTrigger>
<TooltipContent>{t("logs.tips")}</TooltipContent>
</Tooltip>
</div>
)}
<LazyLog
ref={lazyLogRef}
enableLineNumbers={false}
selectableLines
lineClassName="text-primary bg-background"
highlightLineClassName="bg-primary/20"
onRowClick={handleRowClick}
formatPart={formatPart}
text={logs.join("\n")}
follow={follow}
onScroll={onScroll}
loadingComponent={
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
}
loading={isLoading}
/>
</>
<>
{follow && !logSettings.disableStreaming && (
<div className="absolute right-1 top-3">
<Tooltip>
<TooltipTrigger>
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
</TooltipTrigger>
<TooltipContent>{t("logs.tips")}</TooltipContent>
</Tooltip>
</div>
)}
/>
<VList
ref={listRef}
itemSize={isDesktop ? ROW_HEIGHT_HINT_PX : undefined}
data={logState.entries}
shift={logState.shift}
onScroll={syncScrollState}
onWheel={(e) => {
if (!e.deltaY) return;
scrolledUpRef.current = e.deltaY < 0;
syncScrollState();
}}
>
{(entry) => {
const line = parseLogLines(logService, [entry.text])[0];
return (
<LogLineData
key={entry.id}
line={line}
logService={logService}
onClickSeverity={() =>
setFilterSeverity([line.severity])
}
onSelect={() => setSelectedLog(line)}
/>
);
}}
</VList>
</>
)}
</div>
</div>
+29
View File
@@ -540,6 +540,35 @@ export default function Settings() {
const [editingProfile, setEditingProfile] = useState<
Record<string, string | null>
>({});
// drop pending edits for a camera or profile that no longer exists
useEffect(() => {
if (!config) return;
const isStale = (camera: string, profile?: string | null) =>
!config.cameras[camera] || (!!profile && !config.profiles?.[profile]);
const prune = <T,>(
prev: Record<string, T>,
stale: (key: string, value: T) => boolean,
) => {
const next = Object.fromEntries(
Object.entries(prev).filter(([key, value]) => !stale(key, value)),
);
return Object.keys(next).length === Object.keys(prev).length
? prev
: next;
};
setEditingProfile((prev) => prune(prev, isStale));
setPendingDataBySection((prev) =>
prune(prev, (key) => {
const [camera, section] = key.split("::");
return (
section !== undefined &&
isStale(camera, parseProfileFromSectionPath(section).profileName)
);
}),
);
}, [config]);
const [profilesUIEnabled, setProfilesUIEnabled] = useState(false);
const allProfileNames = useMemo(() => {