mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-31 19:00:30 +03:00
Compare commits
12
Commits
eecc43ccef
...
0c52a3175d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c52a3175d | ||
|
|
4c648f8147 | ||
|
|
e0d4337a25 | ||
|
|
5e689f2d85 | ||
|
|
57a2765d00 | ||
|
|
dd77bae4f7 | ||
|
|
8a98d7c9b1 | ||
|
|
8a8da663c0 | ||
|
|
eddc9fccd1 | ||
|
|
84f981a77c | ||
|
|
4100383738 | ||
|
|
fa30a7e1ae |
@@ -5,7 +5,7 @@ aiohttp == 3.12.*
|
|||||||
starlette == 0.47.*
|
starlette == 0.47.*
|
||||||
starlette-context == 0.5.*
|
starlette-context == 0.5.*
|
||||||
fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
|
fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
|
||||||
uvicorn == 0.46.*
|
uvicorn == 0.52.*
|
||||||
slowapi == 0.1.*
|
slowapi == 0.1.*
|
||||||
joserfc == 1.6.*
|
joserfc == 1.6.*
|
||||||
cryptography == 46.0.*
|
cryptography == 46.0.*
|
||||||
@@ -61,7 +61,7 @@ rapidfuzz==3.12.*
|
|||||||
argcomplete==2.0.*
|
argcomplete==2.0.*
|
||||||
contextlib2==0.6.*
|
contextlib2==0.6.*
|
||||||
future==0.18.*
|
future==0.18.*
|
||||||
netaddr==0.8.*
|
netaddr==1.3.*
|
||||||
netifaces==0.10.*
|
netifaces==0.10.*
|
||||||
prometheus-client == 0.26.*
|
prometheus-client == 0.26.*
|
||||||
# TFLite
|
# TFLite
|
||||||
@@ -73,4 +73,4 @@ faster-whisper==1.2.*
|
|||||||
librosa==0.11.*
|
librosa==0.11.*
|
||||||
soundfile==0.13.*
|
soundfile==0.13.*
|
||||||
# Memory profiling
|
# Memory profiling
|
||||||
memray == 1.15.*
|
memray == 1.20.*
|
||||||
|
|||||||
@@ -786,6 +786,13 @@ def auth(request: Request):
|
|||||||
|
|
||||||
user = token.claims.get("sub")
|
user = token.claims.get("sub")
|
||||||
role = token.claims.get("role")
|
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())
|
current_time = int(time.time())
|
||||||
|
|
||||||
# if the jwt is expired
|
# if the jwt is expired
|
||||||
|
|||||||
+14
-4
@@ -1038,6 +1038,10 @@ def export_recording_custom(
|
|||||||
if camera_validation_error is not None:
|
if camera_validation_error is not None:
|
||||||
return camera_validation_error
|
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
|
playback_source = body.source
|
||||||
friendly_name = body.name
|
friendly_name = body.name
|
||||||
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
|
existing_image, image_validation_error = _sanitize_existing_image(body.image_path)
|
||||||
@@ -1048,6 +1052,16 @@ def export_recording_custom(
|
|||||||
cpu_fallback = body.cpu_fallback
|
cpu_fallback = body.cpu_fallback
|
||||||
|
|
||||||
export_case_id = body.export_case_id
|
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)
|
case_validation_error = _validate_export_case(export_case_id)
|
||||||
if case_validation_error is not None:
|
if case_validation_error is not None:
|
||||||
return case_validation_error
|
return case_validation_error
|
||||||
@@ -1064,10 +1078,6 @@ def export_recording_custom(
|
|||||||
status_code=400,
|
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:
|
if not is_admin:
|
||||||
for args_label, args_value in [
|
for args_label, args_value in [
|
||||||
("input", ffmpeg_input_args),
|
("input", ffmpeg_input_args),
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ async def camera_ptz_info(request: Request, camera_name: str):
|
|||||||
future = asyncio.run_coroutine_threadsafe(
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
request.app.onvif.get_camera_info(camera_name), request.app.onvif.loop
|
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)
|
return JSONResponse(content=result)
|
||||||
else:
|
else:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@@ -412,11 +412,8 @@ async def no_recordings(
|
|||||||
if not camera_list:
|
if not camera_list:
|
||||||
return JSONResponse(content=[])
|
return JSONResponse(content=[])
|
||||||
|
|
||||||
before = params.before or datetime.datetime.now().timestamp()
|
before = params.before or datetime.now().timestamp()
|
||||||
after = (
|
after = params.after or (datetime.now() - timedelta(hours=1)).timestamp()
|
||||||
params.after
|
|
||||||
or (datetime.datetime.now() - datetime.timedelta(hours=1)).timestamp()
|
|
||||||
)
|
|
||||||
scale = params.scale
|
scale = params.scale
|
||||||
|
|
||||||
recordings: list[tuple[float, float]] = []
|
recordings: list[tuple[float, float]] = []
|
||||||
|
|||||||
@@ -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(
|
def get_most_recent_preview_frame(
|
||||||
camera: str, before: float | None = None
|
camera: str, before: float | None = None
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
@@ -79,7 +85,7 @@ def get_most_recent_preview_frame(
|
|||||||
preview_files = [
|
preview_files = [
|
||||||
f
|
f
|
||||||
for f in os.listdir(PREVIEW_CACHE_DIR)
|
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}")
|
and f.endswith(f".{PREVIEW_FRAME_TYPE}")
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -276,7 +282,7 @@ class PreviewRecorder:
|
|||||||
start_file = f"{file_start}{start_ts}.webp"
|
start_file = f"{file_start}{start_ts}.webp"
|
||||||
|
|
||||||
for file in sorted(os.listdir(os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES))):
|
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
|
continue
|
||||||
|
|
||||||
if file < start_file:
|
if file < start_file:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from frigate.ffmpeg_presets import (
|
|||||||
parse_preset_hardware_acceleration_encode,
|
parse_preset_hardware_acceleration_encode,
|
||||||
)
|
)
|
||||||
from frigate.models import Export, Previews, Recordings, ReviewSegment
|
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.ffmpeg import run_ffmpeg_with_progress
|
||||||
from frigate.util.ownership import chown_to_runtime
|
from frigate.util.ownership import chown_to_runtime
|
||||||
from frigate.util.recording_coverage import (
|
from frigate.util.recording_coverage import (
|
||||||
@@ -1098,7 +1099,7 @@ class RecordingExporter(threading.Thread):
|
|||||||
fallback_preview = None
|
fallback_preview = None
|
||||||
|
|
||||||
for file in sorted(os.listdir(preview_dir)):
|
for file in sorted(os.listdir(preview_dir)):
|
||||||
if not file.startswith(file_start):
|
if not is_camera_preview_frame(file, self.camera):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if file < start_file:
|
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)
|
||||||
@@ -75,6 +75,21 @@ class TestPreviewLoader(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIsNone(get_most_recent_preview_frame(camera))
|
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):
|
def test_get_most_recent_preview_frame_no_directory(self):
|
||||||
shutil.rmtree(PREVIEW_CACHE_DIR)
|
shutil.rmtree(PREVIEW_CACHE_DIR)
|
||||||
self.assertIsNone(get_most_recent_preview_frame("test_camera"))
|
self.assertIsNone(get_most_recent_preview_frame("test_camera"))
|
||||||
|
|||||||
Generated
+548
-2679
File diff suppressed because it is too large
Load Diff
+45
-45
@@ -25,102 +25,102 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@melloware/react-logviewer": "^6.1.2",
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.15",
|
"@radix-ui/react-aspect-ratio": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.20",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.3.7",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.23",
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
"@radix-ui/react-popover": "^1.1.6",
|
"@radix-ui/react-popover": "^1.1.23",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.16",
|
||||||
"@radix-ui/react-radio-group": "^1.2.3",
|
"@radix-ui/react-radio-group": "^1.4.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.15",
|
||||||
"@radix-ui/react-slider": "^1.2.3",
|
"@radix-ui/react-slider": "^1.4.7",
|
||||||
"@radix-ui/react-slot": "1.2.4",
|
"@radix-ui/react-slot": "1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.1.3",
|
"@radix-ui/react-switch": "^1.3.7",
|
||||||
"@radix-ui/react-tabs": "^1.1.3",
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
"@radix-ui/react-toggle": "^1.1.2",
|
"@radix-ui/react-toggle": "^1.1.18",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
"@radix-ui/react-toggle-group": "^1.1.19",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
"@rjsf/core": "^6.10.0",
|
"@rjsf/core": "^6.10.0",
|
||||||
"@rjsf/shadcn": "^6.10.0",
|
"@rjsf/shadcn": "^6.10.0",
|
||||||
"@rjsf/utils": "^6.10.0",
|
"@rjsf/utils": "^6.10.0",
|
||||||
"@rjsf/validator-ajv8": "^6.10.0",
|
"@rjsf/validator-ajv8": "^6.10.0",
|
||||||
"apexcharts": "^7.3.0",
|
"apexcharts": "^7.3.0",
|
||||||
"axios": "^1.18.0",
|
"axios": "^1.20.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^4.0.2",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"framer-motion": "^13.2.0",
|
"framer-motion": "^13.3.0",
|
||||||
"hls.js": "^1.6.15",
|
"hls.js": "^1.7.3",
|
||||||
"i18next": "^24.2.0",
|
"i18next": "^24.2.0",
|
||||||
"i18next-http-backend": "^4.0.2",
|
"i18next-http-backend": "^4.0.2",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.3.0",
|
||||||
"js-yaml": "^4.3.2",
|
"js-yaml": "^4.3.2",
|
||||||
"konva": "^10.2.3",
|
"konva": "^10.5.0",
|
||||||
"lodash": "^4.18.1",
|
"lodash": "^4.18.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^1.46.0",
|
||||||
"monaco-yaml": "^5.4.1",
|
"monaco-yaml": "^5.5.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nosleep.js": "^0.12.0",
|
"nosleep.js": "^0.12.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.3.0",
|
||||||
"react-apexcharts": "^2.1.1",
|
"react-apexcharts": "^2.1.1",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
"react-device-detect": "^2.2.3",
|
"react-device-detect": "^2.2.3",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.3.0",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-grid-layout": "^2.2.2",
|
"react-grid-layout": "^2.2.4",
|
||||||
"react-hook-form": "^7.72.0",
|
"react-hook-form": "^7.88.0",
|
||||||
"react-i18next": "^15.2.0",
|
"react-i18next": "^15.2.0",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.7.0",
|
||||||
"react-konva": "^19.2.3",
|
"react-konva": "^19.2.7",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-router-dom": "^6.30.6",
|
"react-router-dom": "^6.30.6",
|
||||||
"react-swipeable": "^7.0.2",
|
"react-swipeable": "^7.0.2",
|
||||||
"react-zoom-pan-pinch": "3.4.4",
|
"react-zoom-pan-pinch": "3.4.4",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
"scroll-into-view-if-needed": "^3.1.0",
|
"scroll-into-view-if-needed": "^3.1.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.8",
|
||||||
"sort-by": "^1.2.0",
|
"sort-by": "^1.2.0",
|
||||||
"strftime": "^0.10.3",
|
"strftime": "^0.10.3",
|
||||||
"swr": "^2.4.1",
|
"swr": "^2.5.1",
|
||||||
"tailwind-merge": "^2.4.0",
|
"tailwind-merge": "^2.4.0",
|
||||||
"tailwind-scrollbar": "^3.1.0",
|
"tailwind-scrollbar": "^3.1.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"use-long-press": "^3.2.0",
|
"use-long-press": "^3.3.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"virtua": "^0.51.3",
|
||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.63.0",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@testing-library/jest-dom": "^6.6.2",
|
"@testing-library/jest-dom": "^6.6.2",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/lodash": "^4.17.12",
|
"@types/lodash": "^4.17.25",
|
||||||
"@types/node": "^25.9.6",
|
"@types/node": "^25.9.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@types/strftime": "^0.9.8",
|
"@types/strftime": "^0.9.8",
|
||||||
"@vitejs/plugin-react": "^6.1.1",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"@vitest/coverage-v8": "^4.1.11",
|
"@vitest/coverage-v8": "^4.1.11",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.6.0",
|
||||||
"esbuild": "^0.28.2",
|
"esbuild": "^0.28.2",
|
||||||
"eslint": "^10.10.0",
|
"eslint": "^10.10.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.6",
|
"eslint-plugin-prettier": "^5.5.6",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"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",
|
"fake-indexeddb": "^6.0.0",
|
||||||
"globals": "^17.12.0",
|
"globals": "^17.12.0",
|
||||||
"i18next-cli": "^1.5.11",
|
"i18next-cli": "^1.5.11",
|
||||||
|
|||||||
@@ -92,10 +92,12 @@ function PagePanel({ failure }: PanelProps) {
|
|||||||
|
|
||||||
const stale = isStaleAsset(failure);
|
const stale = isStaleAsset(failure);
|
||||||
|
|
||||||
const onCopy = () => {
|
const onCopy = async () => {
|
||||||
// copy() falls back to a prompt and returns false when the clipboard is
|
// copy() falls back to a prompt and resolves false when the clipboard is
|
||||||
// refused, so a success toast has to wait on the result.
|
// 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) {
|
if (copied) {
|
||||||
toast.success(t("button.copiedToClipboard"), { position: "top-center" });
|
toast.success(t("button.copiedToClipboard"), { position: "top-center" });
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ export function MessageBubble({
|
|||||||
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const handleCopy = useCallback(() => {
|
const handleCopy = useCallback(async () => {
|
||||||
const text = content?.trim() || "";
|
const text = content?.trim() || "";
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (copy(text)) {
|
if (await copy(text)) {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
toast.success(t("button.copiedToClipboard", { ns: "common" }));
|
toast.success(t("button.copiedToClipboard", { ns: "common" }));
|
||||||
setTimeout(() => setCopied(false), 2000);
|
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 { Button, buttonVariants } from "../ui/button";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -66,7 +67,8 @@ import { z } from "zod";
|
|||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import ActivityIndicator from "../indicators/activity-indicator";
|
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 { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import * as LuIcons from "react-icons/lu";
|
import * as LuIcons from "react-icons/lu";
|
||||||
@@ -499,9 +501,8 @@ function NewGroupDialog({
|
|||||||
const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
|
const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const [, , , deleteGridLayout] = useUserPersistence(
|
const { auth } = useContext(AuthContext);
|
||||||
`${activeGroup}-draggable-layout`,
|
const username = auth?.user?.username;
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -513,15 +514,14 @@ function NewGroupDialog({
|
|||||||
|
|
||||||
const onDeleteGroup = useCallback(
|
const onDeleteGroup = useCallback(
|
||||||
async (name: string) => {
|
async (name: string) => {
|
||||||
deleteGridLayout();
|
|
||||||
deleteGroup();
|
|
||||||
|
|
||||||
await axios
|
await axios
|
||||||
.put(`config/set?camera_groups.${name}`, { requires_restart: 0 })
|
.put(`config/set?camera_groups.${name}`, { requires_restart: 0 })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
|
deleteUserNamespacedKey(`${name}-draggable-layout`, username);
|
||||||
if (activeGroup == name) {
|
if (activeGroup == name) {
|
||||||
// deleting current group
|
// deleting current group
|
||||||
|
deleteGroup();
|
||||||
setGroup("default");
|
setGroup("default");
|
||||||
}
|
}
|
||||||
updateConfig();
|
updateConfig();
|
||||||
@@ -557,15 +557,7 @@ function NewGroupDialog({
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[updateConfig, activeGroup, setGroup, setOpen, deleteGroup, username, t],
|
||||||
updateConfig,
|
|
||||||
activeGroup,
|
|
||||||
setGroup,
|
|
||||||
setOpen,
|
|
||||||
deleteGroup,
|
|
||||||
deleteGridLayout,
|
|
||||||
t,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSave = () => {
|
const onSave = () => {
|
||||||
|
|||||||
@@ -203,7 +203,8 @@ export function PolygonCanvas({
|
|||||||
if (stage) {
|
if (stage) {
|
||||||
// we add an unfilled line for adding points when finished
|
// we add an unfilled line for adding points when finished
|
||||||
const index = e.target.index - (activePolygon.isFinished ? 2 : 1);
|
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) {
|
if (snapPoints) {
|
||||||
// Snap to other polygons' edges
|
// Snap to other polygons' edges
|
||||||
|
|||||||
@@ -184,8 +184,3 @@ html {
|
|||||||
.react-grid-layout .react-grid-item {
|
.react-grid-layout .react-grid-item {
|
||||||
transition: none !important;
|
transition: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-lazylog,
|
|
||||||
.react-lazylog-searchbar {
|
|
||||||
background-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ function ConfigEditor() {
|
|||||||
hover: true,
|
hover: true,
|
||||||
completion: true,
|
completion: true,
|
||||||
validate: true,
|
validate: true,
|
||||||
format: true,
|
format: { enable: true },
|
||||||
schemas: [
|
schemas: [
|
||||||
{
|
{
|
||||||
uri: `${apiHost}api/config/schema.json`,
|
uri: `${apiHost}api/config/schema.json`,
|
||||||
|
|||||||
+200
-168
@@ -8,7 +8,13 @@ import {
|
|||||||
logTypes,
|
logTypes,
|
||||||
} from "@/types/log";
|
} from "@/types/log";
|
||||||
import copy from "copy-to-clipboard";
|
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 axios from "axios";
|
||||||
import LogInfoDialog from "@/components/overlay/LogInfoDialog";
|
import LogInfoDialog from "@/components/overlay/LogInfoDialog";
|
||||||
import { LogChip } from "@/components/indicators/Chip";
|
import { LogChip } from "@/components/indicators/Chip";
|
||||||
@@ -21,34 +27,110 @@ import { cn } from "@/lib/utils";
|
|||||||
import { parseLogLines } from "@/utils/logUtil";
|
import { parseLogLines } from "@/utils/logUtil";
|
||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
import scrollIntoView from "scroll-into-view-if-needed";
|
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 useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import EnhancedScrollFollow from "@/components/dynamic/EnhancedScrollFollow";
|
|
||||||
import { MdCircle } from "react-icons/md";
|
import { MdCircle } from "react-icons/md";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { debounce } from "lodash";
|
import { isDesktop, isIOS, isMobile } from "react-device-detect";
|
||||||
import { isIOS, isMobile } from "react-device-detect";
|
|
||||||
import { isPWA } from "@/utils/isPWA";
|
import { isPWA } from "@/utils/isPWA";
|
||||||
import { isInIframe } from "@/utils/isIFrame";
|
import { isInIframe } from "@/utils/isIFrame";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import WsMessageFeed from "@/components/ws/WsMessageFeed";
|
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() {
|
function Logs() {
|
||||||
const { t } = useTranslation(["views/system"]);
|
const { t } = useTranslation(["views/system"]);
|
||||||
const [logService, setLogService] = useState<LogType>("frigate");
|
const [logService, setLogService] = useState<LogType>("frigate");
|
||||||
const isWebsocket = logService === "websocket";
|
const isWebsocket = logService === "websocket";
|
||||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||||
const lazyLogWrapperRef = useRef<HTMLDivElement>(null);
|
const logWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const [logs, setLogs] = useState<string[]>([]);
|
const listRef = useRef<VListHandle>(null);
|
||||||
|
const [logState, setLogState] = useState<LogState>({
|
||||||
|
entries: [],
|
||||||
|
shift: false,
|
||||||
|
stick: true,
|
||||||
|
});
|
||||||
const [filterSeverity, setFilterSeverity] = useState<LogSeverity[]>();
|
const [filterSeverity, setFilterSeverity] = useState<LogSeverity[]>();
|
||||||
const [selectedLog, setSelectedLog] = useState<LogLine>();
|
const [selectedLog, setSelectedLog] = useState<LogLine>();
|
||||||
const lazyLogRef = useRef<LazyLog>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [follow, setFollow] = useState(true);
|
||||||
const lastFetchedIndexRef = useRef(-1);
|
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(() => {
|
useEffect(() => {
|
||||||
document.title = t("documentTitle.logs." + logService);
|
document.title = t("documentTitle.logs." + logService);
|
||||||
@@ -102,8 +184,7 @@ function Logs() {
|
|||||||
response.data &&
|
response.data &&
|
||||||
Array.isArray(response.data.lines)
|
Array.isArray(response.data.lines)
|
||||||
) {
|
) {
|
||||||
const filteredLines = filterLines(response.data.lines);
|
return response.data.lines as string[];
|
||||||
return filteredLines;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@@ -115,26 +196,27 @@ function Logs() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return [];
|
return null;
|
||||||
},
|
},
|
||||||
[logService, filterLines, t],
|
[logService, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchInitialLogs = useCallback(async () => {
|
const fetchInitialLogs = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`logs/${logService}`, {
|
const response = await axios.get(`logs/${logService}`, {
|
||||||
params: { start: filterSeverity ? 0 : -100 },
|
params: { start: filterSeverity?.length ? 0 : -100 },
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
response.status === 200 &&
|
response.status === 200 &&
|
||||||
response.data &&
|
response.data &&
|
||||||
Array.isArray(response.data.lines)
|
Array.isArray(response.data.lines)
|
||||||
) {
|
) {
|
||||||
const filteredLines = filterLines(response.data.lines);
|
resetLines(filterLines(response.data.lines));
|
||||||
setLogs(filteredLines);
|
|
||||||
|
// A filtered load fetches the whole file, so nothing older remains
|
||||||
lastFetchedIndexRef.current =
|
lastFetchedIndexRef.current =
|
||||||
response.data.totalLines - filteredLines.length;
|
response.data.totalLines - response.data.lines.length;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@@ -145,7 +227,7 @@ function Logs() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [logService, filterLines, filterSeverity, t]);
|
}, [logService, filterLines, filterSeverity, resetLines, t]);
|
||||||
|
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
@@ -180,9 +262,7 @@ function Logs() {
|
|||||||
return filterSeverity.includes(parsedLine.severity);
|
return filterSeverity.includes(parsedLine.severity);
|
||||||
})
|
})
|
||||||
: lines;
|
: lines;
|
||||||
if (filteredLines.length > 0) {
|
appendLines(filteredLines);
|
||||||
lazyLogRef.current?.appendLines(filteredLines);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Process next chunk
|
// Process next chunk
|
||||||
return processStreamChunk(reader);
|
return processStreamChunk(reader);
|
||||||
@@ -215,17 +295,19 @@ function Logs() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [logService, filterSeverity, t]);
|
}, [logService, filterSeverity, appendLines, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWebsocket) {
|
if (isWebsocket) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setLogs([]);
|
resetLines([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setLogs([]);
|
setFollow(true);
|
||||||
|
scrolledUpRef.current = false;
|
||||||
|
resetLines([]);
|
||||||
lastFetchedIndexRef.current = -1;
|
lastFetchedIndexRef.current = -1;
|
||||||
fetchInitialLogs().then(() => {
|
fetchInitialLogs().then(() => {
|
||||||
// Start streaming after initial load
|
// Start streaming after initial load
|
||||||
@@ -243,70 +325,52 @@ function Logs() {
|
|||||||
|
|
||||||
// handlers
|
// handlers
|
||||||
|
|
||||||
const prependLines = useCallback((newLines: string[]) => {
|
const loadOlderLines = useCallback(async () => {
|
||||||
if (!lazyLogRef.current) return;
|
const end = lastFetchedIndexRef.current;
|
||||||
|
if (loadingOlderRef.current || end <= 0) return;
|
||||||
|
|
||||||
const newLinesArray = newLines.map(
|
loadingOlderRef.current = true;
|
||||||
(line) => new Uint8Array(new TextEncoder().encode(line + "\n")),
|
const start = Math.max(0, end - OLDER_LINES_CHUNK_SIZE);
|
||||||
);
|
const lines = await fetchLogRange(start, end);
|
||||||
|
loadingOlderRef.current = false;
|
||||||
|
|
||||||
lazyLogRef.current.setState((prevState) => ({
|
// A service or filter change resets the index while the request is in flight
|
||||||
...prevState,
|
if (!lines || lastFetchedIndexRef.current !== end) return;
|
||||||
lines: prevState.lines.unshift(...newLinesArray),
|
|
||||||
count: prevState.count + newLines.length,
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// debounced
|
lastFetchedIndexRef.current = start;
|
||||||
const handleScroll = useMemo(
|
prependLines(lines);
|
||||||
() =>
|
}, [fetchLogRange, prependLines]);
|
||||||
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);
|
|
||||||
|
|
||||||
fetchLogRange(nextStart, nextEnd).then((newLines) => {
|
// Runs on scroll events and on wheel or key input, because input at the top
|
||||||
if (newLines.length > 0) {
|
// or bottom edge doesn't scroll and fires no scroll event
|
||||||
prependLines(newLines);
|
const syncScrollState = useCallback(() => {
|
||||||
lastFetchedIndexRef.current = nextStart;
|
const list = listRef.current;
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
lazyLogRef.current?.listRef.current?.scrollTo(
|
setFollow(isFollowing());
|
||||||
newLines.length *
|
|
||||||
lazyLogRef.current?.listRef.current?.getItemSize(1),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsLoading(false);
|
if (list.scrollOffset < list.viewportSize) {
|
||||||
}
|
loadOlderLines();
|
||||||
}, 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"));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [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(() => {
|
const handleDownloadLogs = useCallback(() => {
|
||||||
axios
|
axios
|
||||||
@@ -329,77 +393,34 @@ function Logs() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [logService]);
|
}, [logService]);
|
||||||
|
|
||||||
const handleRowClick = useCallback(
|
|
||||||
(rowInfo: { lineNumber: number; rowIndex: number }) => {
|
|
||||||
const clickedLine = parseLogLines(logService, [
|
|
||||||
logs[rowInfo.rowIndex],
|
|
||||||
])[0];
|
|
||||||
setSelectedLog(clickedLine);
|
|
||||||
},
|
|
||||||
[logs, logService],
|
|
||||||
);
|
|
||||||
|
|
||||||
// keyboard listener
|
// keyboard listener
|
||||||
|
|
||||||
useKeyboardListener(
|
useKeyboardListener(
|
||||||
["PageDown", "PageUp", "ArrowDown", "ArrowUp"],
|
["PageDown", "PageUp", "ArrowDown", "ArrowUp"],
|
||||||
(key, modifiers) => {
|
(key, modifiers) => {
|
||||||
if (!key || !modifiers.down || !lazyLogWrapperRef.current) {
|
const list = listRef.current;
|
||||||
|
if (!key || !modifiers.down || !list) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container =
|
const rowHeight = list.getItemSize(list.findItemIndex(list.scrollOffset));
|
||||||
lazyLogWrapperRef.current.querySelector(".react-lazylog");
|
if (!rowHeight) {
|
||||||
|
|
||||||
const logLineHeight = container?.querySelector(".log-line")?.clientHeight;
|
|
||||||
|
|
||||||
if (!logLineHeight) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollAmount = key.includes("Page")
|
const rows = key.includes("Page") ? 10 : 1;
|
||||||
? logLineHeight * 10
|
|
||||||
: logLineHeight;
|
|
||||||
const direction = key.includes("Down") ? 1 : -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;
|
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(() => {
|
useEffect(() => {
|
||||||
const handleCopy = (e: ClipboardEvent) => {
|
const handleCopy = (e: ClipboardEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!lazyLogWrapperRef.current) return;
|
if (!logWrapperRef.current) return;
|
||||||
|
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!selection) return;
|
if (!selection) return;
|
||||||
@@ -467,7 +488,7 @@ function Logs() {
|
|||||||
e.clipboardData?.setData("text/plain", copyText);
|
e.clipboardData?.setData("text/plain", copyText);
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = lazyLogWrapperRef.current;
|
const content = logWrapperRef.current;
|
||||||
content?.addEventListener("copy", handleCopy);
|
content?.addEventListener("copy", handleCopy);
|
||||||
return () => {
|
return () => {
|
||||||
content?.removeEventListener("copy", handleCopy);
|
content?.removeEventListener("copy", handleCopy);
|
||||||
@@ -488,7 +509,6 @@ function Logs() {
|
|||||||
value={logService}
|
value={logService}
|
||||||
onValueChange={(value: LogType) => {
|
onValueChange={(value: LogType) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
setLogs([]);
|
|
||||||
setFilterSeverity(undefined);
|
setFilterSeverity(undefined);
|
||||||
setLogService(value);
|
setLogService(value);
|
||||||
}
|
}
|
||||||
@@ -582,44 +602,56 @@ function Logs() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={lazyLogWrapperRef} className="size-full">
|
<div
|
||||||
|
ref={logWrapperRef}
|
||||||
|
className="min-h-0 flex-1"
|
||||||
|
onPointerDown={() => {
|
||||||
|
scrolledUpRef.current = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
) : (
|
) : (
|
||||||
<EnhancedScrollFollow
|
<>
|
||||||
startFollowing={!isLoading}
|
{follow && !logSettings.disableStreaming && (
|
||||||
onCustomScroll={handleScroll}
|
<div className="absolute right-1 top-3">
|
||||||
render={({ follow, onScroll }) => (
|
<Tooltip>
|
||||||
<>
|
<TooltipTrigger>
|
||||||
{follow && !logSettings.disableStreaming && (
|
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
|
||||||
<div className="absolute right-1 top-3">
|
</TooltipTrigger>
|
||||||
<Tooltip>
|
<TooltipContent>{t("logs.tips")}</TooltipContent>
|
||||||
<TooltipTrigger>
|
</Tooltip>
|
||||||
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
|
</div>
|
||||||
</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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
/>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -540,6 +540,35 @@ export default function Settings() {
|
|||||||
const [editingProfile, setEditingProfile] = useState<
|
const [editingProfile, setEditingProfile] = useState<
|
||||||
Record<string, string | null>
|
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 [profilesUIEnabled, setProfilesUIEnabled] = useState(false);
|
||||||
|
|
||||||
const allProfileNames = useMemo(() => {
|
const allProfileNames = useMemo(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user