diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index d87dbb239..000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,439 +0,0 @@ -# GitHub Copilot Instructions for Frigate NVR - -This document provides coding guidelines and best practices for contributing to Frigate NVR, a complete and local NVR designed for Home Assistant with AI object detection. - -## Project Overview - -Frigate NVR is a realtime object detection system for IP cameras that uses: - -- **Backend**: Python 3.13+ with FastAPI, OpenCV, TensorFlow/ONNX -- **Frontend**: React with TypeScript, Vite, TailwindCSS -- **Architecture**: Multiprocessing design with ZMQ and MQTT communication -- **Focus**: Minimal resource usage with maximum performance - -## Code Review Guidelines - -When reviewing code, do NOT comment on: - -- Missing imports - Static analysis tooling catches these -- Code formatting - Ruff (Python) and Prettier (TypeScript/React) handle formatting -- Minor style inconsistencies already enforced by linters - -## Python Backend Standards - -### Python Requirements - -- **Compatibility**: Python 3.13+ -- **Language Features**: Use modern Python features: - - Pattern matching - - Type hints (comprehensive typing preferred) - - f-strings (preferred over `%` or `.format()`) - - Dataclasses - - Async/await patterns - -### Code Quality Standards - -- **Formatting**: Ruff (configured in `pyproject.toml`) -- **Linting**: Ruff with rules defined in project config -- **Type Checking**: Use type hints consistently -- **Testing**: unittest framework - use `python3 -u -m unittest` to run tests -- **Language**: American English for all code, comments, and documentation - -### Logging Standards - -- **Logger Pattern**: Use module-level logger - - ```python - import logging - - logger = logging.getLogger(__name__) - ``` - -- **Format Guidelines**: - - No periods at end of log messages - - No sensitive data (keys, tokens, passwords) - - Use lazy logging: `logger.debug("Message with %s", variable)` -- **Log Levels**: - - `debug`: Development and troubleshooting information - - `info`: Important runtime events (startup, shutdown, state changes) - - `warning`: Recoverable issues that should be addressed - - `error`: Errors that affect functionality but don't crash the app - - `exception`: Use in except blocks to include traceback - -### Error Handling - -- **Exception Types**: Choose most specific exception available -- **Try/Catch Best Practices**: - - Only wrap code that can throw exceptions - - Keep try blocks minimal - process data after the try/except - - Avoid bare exceptions except in background tasks - - Bad pattern: - - ```python - try: - data = await device.get_data() # Can throw - # ❌ Don't process data inside try block - processed = data.get("value", 0) * 100 - result = processed - except DeviceError: - logger.error("Failed to get data") - ``` - - Good pattern: - - ```python - try: - data = await device.get_data() # Can throw - except DeviceError: - logger.error("Failed to get data") - return - - # ✅ Process data outside try block - processed = data.get("value", 0) * 100 - result = processed - ``` - -### Async Programming - -- **External I/O**: All external I/O operations must be async -- **Best Practices**: - - Avoid sleeping in loops - use `asyncio.sleep()` not `time.sleep()` - - Avoid awaiting in loops - use `asyncio.gather()` instead - - No blocking calls in async functions - - Use `asyncio.create_task()` for background operations -- **Thread Safety**: Use proper synchronization for shared state - -### Documentation Standards - -- **Module Docstrings**: Concise descriptions at top of files - ```python - """Utilities for motion detection and analysis.""" - ``` -- **Function Docstrings**: Required for public functions and methods - - ```python - async def process_frame(frame: ndarray, config: Config) -> Detection: - """Process a video frame for object detection. - - Args: - frame: The video frame as numpy array - config: Detection configuration - - Returns: - Detection results with bounding boxes - """ - ``` - -- **Comment Style**: - - Explain the "why" not just the "what" - - Keep lines under 88 characters when possible - - Use clear, descriptive comments - -### File Organization - -- **API Endpoints**: `frigate/api/` - FastAPI route handlers -- **Configuration**: `frigate/config/` - Configuration parsing and validation -- **Detectors**: `frigate/detectors/` - Object detection backends -- **Events**: `frigate/events/` - Event management and storage -- **Utilities**: `frigate/util/` - Shared utility functions - -## Frontend (React/TypeScript) Standards - -### Internationalization (i18n) - -- **CRITICAL**: Never write user-facing strings directly in components -- **Always use react-i18next**: Import and use the `t()` function - - ```tsx - import { useTranslation } from "react-i18next"; - - function MyComponent() { - const { t } = useTranslation(["views/live"]); - return
{t("camera_not_found")}
; - } - ``` - -- **Translation Files**: Add English strings to the appropriate json files in `web/public/locales/en` -- **Namespaces**: Organize translations by feature/view (e.g., `views/live`, `common`, `views/system`) - -### Code Quality - -- **Linting**: ESLint (see `web/.eslintrc.cjs`) -- **Formatting**: Prettier with Tailwind CSS plugin -- **Type Safety**: TypeScript strict mode enabled - -### Component Patterns - -- **UI Components**: Use Radix UI primitives (in `web/src/components/ui/`) -- **Styling**: TailwindCSS with `cn()` utility for class merging -- **State Management**: React hooks (useState, useEffect, useCallback, useMemo) -- **Data Fetching**: Custom hooks with proper loading and error states - -### ESLint Rules - -Key rules enforced: - -- `react-hooks/rules-of-hooks`: error -- `react-hooks/exhaustive-deps`: error -- `no-console`: error (use proper logging or remove) -- `@typescript-eslint/no-explicit-any`: warn (always use proper types instead of `any`) -- Unused variables must be prefixed with `_` -- Comma dangles required for multiline objects/arrays - -### File Organization - -- **Pages**: `web/src/pages/` - Route components -- **Views**: `web/src/views/` - Complex view components -- **Components**: `web/src/components/` - Reusable components -- **Hooks**: `web/src/hooks/` - Custom React hooks -- **API**: `web/src/api/` - API client functions -- **Types**: `web/src/types/` - TypeScript type definitions - -## Testing Requirements - -### Backend Testing - -- **Framework**: Python unittest -- **Run Command**: `python3 -u -m unittest` -- **Location**: `frigate/test/` -- **Coverage**: Aim for comprehensive test coverage of core functionality -- **Pattern**: Use `TestCase` classes with descriptive test method names - ```python - class TestMotionDetection(unittest.TestCase): - def test_detects_motion_above_threshold(self): - # Test implementation - ``` - -### Test Best Practices - -- Always have a way to test your work and confirm your changes -- Write tests for bug fixes to prevent regressions -- Test edge cases and error conditions -- Mock external dependencies (cameras, APIs, hardware) -- Use fixtures for test data - -## Development Commands - -### Python Backend - -```bash -# Run all tests -python3 -u -m unittest - -# Run specific test file -python3 -u -m unittest frigate.test.test_ffmpeg_presets - -# Check formatting (Ruff) -ruff format --check frigate/ - -# Apply formatting -ruff format frigate/ - -# Run linter -ruff check frigate/ - -# Type check -python3 -u -m mypy --config-file frigate/mypy.ini frigate -``` - -### Frontend (from web/ directory) - -```bash -# Start dev server (AI agents should never run this directly unless asked) -npm run dev - -# Build for production -npm run build - -# Run linter -npm run lint - -# Fix linting issues -npm run lint:fix - -# Format code -npm run prettier:write - -# E2E: first-time setup -npm install -npx playwright install chromium - -# E2E: build the app and run all tests -npm run e2e:build && npm run e2e - -# E2E: interactive UI for debugging -npm run e2e:ui - -# E2E: run a specific spec -npx playwright test --config e2e/playwright.config.ts e2e/specs/live.spec.ts - -# E2E: filter by name, or run only desktop/mobile -npx playwright test --config e2e/playwright.config.ts --grep="severity tab" -npx playwright test --config e2e/playwright.config.ts --project=desktop - -# E2E: regenerate mock data after backend model changes (from repo root) -PYTHONPATH=. python3 web/e2e/fixtures/mock-data/generate-mock-data.py - -# Regenerate config translations from Pydantic models — outputs to -# web/public/locales/en/config/{global,cameras}.json. NEVER edit those -# JSON files by hand; change the Pydantic field title/description and -# re-run this script. (from repo root) -python3 generate_config_translations.py - -# Extract i18n keys from source into the locale files after adding -# new t() calls. Use the :ci variant to verify the locale files are -# in sync with source (fails if extraction would change anything). -npm run i18n:extract -npm run i18n:extract:ci -``` - -### Docker Development - -AI agents should never run these commands directly unless instructed. - -```bash -# Build local image -make local - -# Build debug image -make debug -``` - -## Common Patterns - -### API Endpoint Pattern - -```python -from fastapi import APIRouter, Request -from frigate.api.defs.tags import Tags - -router = APIRouter(tags=[Tags.Events]) - -@router.get("/events") -async def get_events(request: Request, limit: int = 100): - """Retrieve events from the database.""" - # Implementation -``` - -### Configuration Access - -```python -# Access Frigate configuration -config: FrigateConfig = request.app.frigate_config -camera_config = config.cameras["front_door"] -``` - -### Database Queries - -```python -from frigate.models import Event - -# Use Peewee ORM for database access -events = ( - Event.select() - .where(Event.camera == camera_name) - .order_by(Event.start_time.desc()) - .limit(limit) -) -``` - -## Common Anti-Patterns to Avoid - -### ❌ Avoid These - -```python -# Blocking operations in async functions -data = requests.get(url) # ❌ Use async HTTP client -time.sleep(5) # ❌ Use asyncio.sleep() - -# Hardcoded strings in React components -
Camera not found
# ❌ Use t("camera_not_found") - -# Missing error handling -data = await api.get_data() # ❌ No exception handling - -# Bare exceptions in regular code -try: - value = await sensor.read() -except Exception: # ❌ Too broad - logger.error("Failed") - -# Returning exceptions in JSON responses -except ValueError as e: - return JSONResponse( - content={"success": False, "message": str(e)}, - ) -``` - -### ✅ Use These Instead - -```python -# Async operations -import aiohttp -async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - data = await response.json() - -await asyncio.sleep(5) # ✅ Non-blocking - -# Translatable strings in React -const { t } = useTranslation(); -
{t("camera_not_found")}
# ✅ Translatable - -# Proper error handling -try: - data = await api.get_data() -except ApiException as err: - logger.error("API error: %s", err) - raise - -# Specific exceptions -try: - value = await sensor.read() -except SensorException as err: # ✅ Specific - logger.exception("Failed to read sensor") - -# Safe error responses -except ValueError: - logger.exception("Invalid parameters for API request") - return JSONResponse( - content={ - "success": False, - "message": "Invalid request parameters", - }, - ) -``` - -## WebSocket Broadcasts - -Outbound WebSocket broadcasts go through a per-recipient classifier in `frigate/comms/ws.py` that enforces camera-level access. **The classifier is fail-closed: any topic it doesn't recognize is dropped for every client.** New outbound topics must be classified there or they'll silently disappear. - -## Project-Specific Conventions - -### Configuration Files - -- Main config: `config/config.yml` - -### Directory Structure - -- Backend code: `frigate/` -- Frontend code: `web/` -- Docker files: `docker/` -- Documentation: `docs/` -- Database migrations: `migrations/` - -### Code Style Conformance - -Always conform new and refactored code to the existing coding style in the project: - -- Follow established patterns in similar files -- Match indentation and formatting of surrounding code -- Use consistent naming conventions (snake_case for Python, camelCase for TypeScript) -- Maintain the same level of verbosity in comments and docstrings - -## Additional Resources - -- Documentation: https://docs.frigate.video -- Main Repository: https://github.com/blakeblackshear/frigate -- Home Assistant Integration: https://github.com/blakeblackshear/frigate-hass-integration diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..61b8373a8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,439 @@ +# Agent Instructions for Frigate NVR + +This document provides coding guidelines and best practices for contributing to Frigate NVR, a complete and local NVR designed for Home Assistant with AI object detection. + +## Project Overview + +Frigate NVR is a realtime object detection system for IP cameras that uses: + +- **Backend**: Python 3.13+ with FastAPI, OpenCV, TensorFlow/ONNX +- **Frontend**: React with TypeScript, Vite, TailwindCSS +- **Architecture**: Multiprocessing design with ZMQ and MQTT communication +- **Focus**: Minimal resource usage with maximum performance + +## Code Review Guidelines + +When reviewing code, do NOT comment on: + +- Missing imports - Static analysis tooling catches these +- Code formatting - Ruff (Python) and Prettier (TypeScript/React) handle formatting +- Minor style inconsistencies already enforced by linters + +## Python Backend Standards + +### Python Requirements + +- **Compatibility**: Python 3.13+ +- **Language Features**: Use modern Python features: + - Pattern matching + - Type hints (comprehensive typing preferred) + - f-strings (preferred over `%` or `.format()`) + - Dataclasses + - Async/await patterns + +### Code Quality Standards + +- **Formatting**: Ruff (configured in `pyproject.toml`) +- **Linting**: Ruff with rules defined in project config +- **Type Checking**: Use type hints consistently +- **Testing**: unittest framework - use `python3 -u -m unittest` to run tests +- **Language**: American English for all code, comments, and documentation + +### Logging Standards + +- **Logger Pattern**: Use module-level logger + + ```python + import logging + + logger = logging.getLogger(__name__) + ``` + +- **Format Guidelines**: + - No periods at end of log messages + - No sensitive data (keys, tokens, passwords) + - Use lazy logging: `logger.debug("Message with %s", variable)` +- **Log Levels**: + - `debug`: Development and troubleshooting information + - `info`: Important runtime events (startup, shutdown, state changes) + - `warning`: Recoverable issues that should be addressed + - `error`: Errors that affect functionality but don't crash the app + - `exception`: Use in except blocks to include traceback + +### Error Handling + +- **Exception Types**: Choose most specific exception available +- **Try/Catch Best Practices**: + - Only wrap code that can throw exceptions + - Keep try blocks minimal - process data after the try/except + - Avoid bare exceptions except in background tasks + + Bad pattern: + + ```python + try: + data = await device.get_data() # Can throw + # ❌ Don't process data inside try block + processed = data.get("value", 0) * 100 + result = processed + except DeviceError: + logger.error("Failed to get data") + ``` + + Good pattern: + + ```python + try: + data = await device.get_data() # Can throw + except DeviceError: + logger.error("Failed to get data") + return + + # ✅ Process data outside try block + processed = data.get("value", 0) * 100 + result = processed + ``` + +### Async Programming + +- **External I/O**: All external I/O operations must be async +- **Best Practices**: + - Avoid sleeping in loops - use `asyncio.sleep()` not `time.sleep()` + - Avoid awaiting in loops - use `asyncio.gather()` instead + - No blocking calls in async functions + - Use `asyncio.create_task()` for background operations +- **Thread Safety**: Use proper synchronization for shared state + +### Documentation Standards + +- **Module Docstrings**: Concise descriptions at top of files + ```python + """Utilities for motion detection and analysis.""" + ``` +- **Function Docstrings**: Required for public functions and methods + + ```python + async def process_frame(frame: ndarray, config: Config) -> Detection: + """Process a video frame for object detection. + + Args: + frame: The video frame as numpy array + config: Detection configuration + + Returns: + Detection results with bounding boxes + """ + ``` + +- **Comment Style**: + - Explain the "why" not just the "what" + - Keep lines under 88 characters when possible + - Use clear, descriptive comments + +### File Organization + +- **API Endpoints**: `frigate/api/` - FastAPI route handlers +- **Configuration**: `frigate/config/` - Configuration parsing and validation +- **Detectors**: `frigate/detectors/` - Object detection backends +- **Events**: `frigate/events/` - Event management and storage +- **Utilities**: `frigate/util/` - Shared utility functions + +## Frontend (React/TypeScript) Standards + +### Internationalization (i18n) + +- **CRITICAL**: Never write user-facing strings directly in components +- **Always use react-i18next**: Import and use the `t()` function + + ```tsx + import { useTranslation } from "react-i18next"; + + function MyComponent() { + const { t } = useTranslation(["views/live"]); + return
{t("camera_not_found")}
; + } + ``` + +- **Translation Files**: Add English strings to the appropriate json files in `web/public/locales/en` +- **Namespaces**: Organize translations by feature/view (e.g., `views/live`, `common`, `views/system`) + +### Code Quality + +- **Linting**: ESLint (see `web/.eslintrc.cjs`) +- **Formatting**: Prettier with Tailwind CSS plugin +- **Type Safety**: TypeScript strict mode enabled + +### Component Patterns + +- **UI Components**: Use Radix UI primitives (in `web/src/components/ui/`) +- **Styling**: TailwindCSS with `cn()` utility for class merging +- **State Management**: React hooks (useState, useEffect, useCallback, useMemo) +- **Data Fetching**: Custom hooks with proper loading and error states + +### ESLint Rules + +Key rules enforced: + +- `react-hooks/rules-of-hooks`: error +- `react-hooks/exhaustive-deps`: error +- `no-console`: error (use proper logging or remove) +- `@typescript-eslint/no-explicit-any`: warn (always use proper types instead of `any`) +- Unused variables must be prefixed with `_` +- Comma dangles required for multiline objects/arrays + +### File Organization + +- **Pages**: `web/src/pages/` - Route components +- **Views**: `web/src/views/` - Complex view components +- **Components**: `web/src/components/` - Reusable components +- **Hooks**: `web/src/hooks/` - Custom React hooks +- **API**: `web/src/api/` - API client functions +- **Types**: `web/src/types/` - TypeScript type definitions + +## Testing Requirements + +### Backend Testing + +- **Framework**: Python unittest +- **Run Command**: `python3 -u -m unittest` +- **Location**: `frigate/test/` +- **Coverage**: Aim for comprehensive test coverage of core functionality +- **Pattern**: Use `TestCase` classes with descriptive test method names + ```python + class TestMotionDetection(unittest.TestCase): + def test_detects_motion_above_threshold(self): + # Test implementation + ``` + +### Test Best Practices + +- Always have a way to test your work and confirm your changes +- Write tests for bug fixes to prevent regressions +- Test edge cases and error conditions +- Mock external dependencies (cameras, APIs, hardware) +- Use fixtures for test data + +## Development Commands + +### Python Backend + +```bash +# Run all tests +python3 -u -m unittest + +# Run specific test file +python3 -u -m unittest frigate.test.test_ffmpeg_presets + +# Check formatting (Ruff) +ruff format --check frigate/ + +# Apply formatting +ruff format frigate/ + +# Run linter +ruff check frigate/ + +# Type check +python3 -u -m mypy --config-file frigate/mypy.ini frigate +``` + +### Frontend (from web/ directory) + +```bash +# Start dev server (AI agents should never run this directly unless asked) +npm run dev + +# Build for production +npm run build + +# Run linter +npm run lint + +# Fix linting issues +npm run lint:fix + +# Format code +npm run prettier:write + +# E2E: first-time setup +npm install +npx playwright install chromium + +# E2E: build the app and run all tests +npm run e2e:build && npm run e2e + +# E2E: interactive UI for debugging +npm run e2e:ui + +# E2E: run a specific spec +npx playwright test --config e2e/playwright.config.ts e2e/specs/live.spec.ts + +# E2E: filter by name, or run only desktop/mobile +npx playwright test --config e2e/playwright.config.ts --grep="severity tab" +npx playwright test --config e2e/playwright.config.ts --project=desktop + +# E2E: regenerate mock data after backend model changes (from repo root) +PYTHONPATH=. python3 web/e2e/fixtures/mock-data/generate-mock-data.py + +# Regenerate config translations from Pydantic models — outputs to +# web/public/locales/en/config/{global,cameras}.json. NEVER edit those +# JSON files by hand; change the Pydantic field title/description and +# re-run this script. (from repo root) +python3 generate_config_translations.py + +# Extract i18n keys from source into the locale files after adding +# new t() calls. Use the :ci variant to verify the locale files are +# in sync with source (fails if extraction would change anything). +npm run i18n:extract +npm run i18n:extract:ci +``` + +### Docker Development + +AI agents should never run these commands directly unless instructed. + +```bash +# Build local image +make local + +# Build debug image +make debug +``` + +## Common Patterns + +### API Endpoint Pattern + +```python +from fastapi import APIRouter, Request +from frigate.api.defs.tags import Tags + +router = APIRouter(tags=[Tags.Events]) + +@router.get("/events") +async def get_events(request: Request, limit: int = 100): + """Retrieve events from the database.""" + # Implementation +``` + +### Configuration Access + +```python +# Access Frigate configuration +config: FrigateConfig = request.app.frigate_config +camera_config = config.cameras["front_door"] +``` + +### Database Queries + +```python +from frigate.models import Event + +# Use Peewee ORM for database access +events = ( + Event.select() + .where(Event.camera == camera_name) + .order_by(Event.start_time.desc()) + .limit(limit) +) +``` + +## Common Anti-Patterns to Avoid + +### ❌ Avoid These + +```python +# Blocking operations in async functions +data = requests.get(url) # ❌ Use async HTTP client +time.sleep(5) # ❌ Use asyncio.sleep() + +# Hardcoded strings in React components +
Camera not found
# ❌ Use t("camera_not_found") + +# Missing error handling +data = await api.get_data() # ❌ No exception handling + +# Bare exceptions in regular code +try: + value = await sensor.read() +except Exception: # ❌ Too broad + logger.error("Failed") + +# Returning exceptions in JSON responses +except ValueError as e: + return JSONResponse( + content={"success": False, "message": str(e)}, + ) +``` + +### ✅ Use These Instead + +```python +# Async operations +import aiohttp +async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + data = await response.json() + +await asyncio.sleep(5) # ✅ Non-blocking + +# Translatable strings in React +const { t } = useTranslation(); +
{t("camera_not_found")}
# ✅ Translatable + +# Proper error handling +try: + data = await api.get_data() +except ApiException as err: + logger.error("API error: %s", err) + raise + +# Specific exceptions +try: + value = await sensor.read() +except SensorException as err: # ✅ Specific + logger.exception("Failed to read sensor") + +# Safe error responses +except ValueError: + logger.exception("Invalid parameters for API request") + return JSONResponse( + content={ + "success": False, + "message": "Invalid request parameters", + }, + ) +``` + +## WebSocket Broadcasts + +Outbound WebSocket broadcasts go through a per-recipient classifier in `frigate/comms/ws.py` that enforces camera-level access. **The classifier is fail-closed: any topic it doesn't recognize is dropped for every client.** New outbound topics must be classified there or they'll silently disappear. + +## Project-Specific Conventions + +### Configuration Files + +- Main config: `config/config.yml` + +### Directory Structure + +- Backend code: `frigate/` +- Frontend code: `web/` +- Docker files: `docker/` +- Documentation: `docs/` +- Database migrations: `migrations/` + +### Code Style Conformance + +Always conform new and refactored code to the existing coding style in the project: + +- Follow established patterns in similar files +- Match indentation and formatting of surrounding code +- Use consistent naming conventions (snake_case for Python, camelCase for TypeScript) +- Maintain the same level of verbosity in comments and docstrings + +## Additional Resources + +- Documentation: https://docs.frigate.video +- Main Repository: https://github.com/blakeblackshear/frigate +- Home Assistant Integration: https://github.com/blakeblackshear/frigate-hass-integration diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index d954bdcd5..d0b18ff80 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -252,6 +252,7 @@ http { include proxy.conf; proxy_cache api_cache; + proxy_cache_key "$scheme$proxy_host$request_uri|$role|$groups|$user"; proxy_cache_lock on; proxy_cache_use_stale updating; proxy_cache_valid 200 5s; diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 9c4e44051..605eff92c 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -2058,6 +2058,47 @@ paths: application/json: schema: $ref: "#/components/schemas/HTTPValidationError" + /genai/models: + get: + tags: + - App + summary: List available GenAI models + description: Returns available models for each configured GenAI provider. + operationId: genai_models_genai_models_get + responses: + "200": + description: Successful Response + content: + application/json: + schema: {} + /genai/probe: + post: + tags: + - App + summary: Probe a GenAI provider without saving config + description: >- + Builds a transient client from the request body and returns its + available models. Used to validate provider credentials in the UI + before saving the configuration. Requires admin role. + operationId: genai_probe_genai_probe_post + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GenAIProbeBody" + responses: + "200": + description: Successful Response + content: + application/json: + schema: {} + "422": + description: Validation Error + content: + application/json: + schema: + $ref: "#/components/schemas/HTTPValidationError" /vainfo: get: tags: @@ -7031,6 +7072,39 @@ components: "john_doe": ["face1.webp", "face2.jpg"], "jane_smith": ["face3.png"] } + GenAIProbeBody: + properties: + provider: + type: string + enum: + - openai + - azure_openai + - gemini + - ollama + - llamacpp + title: Provider + description: GenAI provider to probe + api_key: + anyOf: + - type: string + - type: "null" + title: API Key + description: API key for the provider (when applicable) + base_url: + anyOf: + - type: string + - type: "null" + title: Base URL + description: Base URL for self-hosted or compatible providers + provider_options: + type: object + title: Provider Options + description: Additional provider-specific options + default: {} + type: object + required: + - provider + title: GenAIProbeBody GenerateObjectExamplesBody: properties: model_name: diff --git a/frigate/api/app.py b/frigate/api/app.py index 4fac58a71..2ba016d63 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -34,15 +34,17 @@ from frigate.api.auth import ( from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters from frigate.api.defs.request.app_body import ( AppConfigSetBody, + GenAIProbeBody, MediaSyncBody, ) from frigate.api.defs.tags import Tags -from frigate.config import FrigateConfig +from frigate.config import FrigateConfig, GenAIConfig, GenAIProviderEnum from frigate.config.camera.updater import ( CameraConfigUpdateEnum, CameraConfigUpdateTopic, ) from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector +from frigate.genai import PROVIDERS, load_providers from frigate.jobs.media_sync import ( get_current_media_sync_job, get_media_sync_job_by_id, @@ -75,6 +77,14 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.app]) +# Short timeout for the /genai/probe path. The probe is interactive — fail +# fast on hung providers rather than holding an API worker thread. +_PROBE_TIMEOUT_SECONDS = 10 +# Outer cap that returns control to the caller even if the underlying sync +# HTTP call ignores its timeout. The sync work continues in the background +# thread; only the response is bounded. +_PROBE_OUTER_TIMEOUT_SECONDS = 15 + @router.get( "/", response_class=PlainTextResponse, dependencies=[Depends(allow_public())] @@ -170,6 +180,95 @@ def genai_models(request: Request): return JSONResponse(content=request.app.genai_manager.list_models()) +@router.post( + "/genai/probe", + dependencies=[Depends(require_role(["admin"]))], + summary="Probe a GenAI provider without saving config", + description=( + "Builds a transient client from the request body and returns its " + "available models. Used to validate provider credentials in the UI " + "before saving the configuration." + ), +) +async def genai_probe(body: GenAIProbeBody): + load_providers() + + provider_cls = PROVIDERS.get(body.provider) + if not provider_cls: + return JSONResponse( + status_code=400, + content={"success": False, "message": "Unknown provider"}, + ) + + # The OpenAI-compatible SDKs accept "timeout" as a constructor kwarg via + # provider_options; other plugins use GenAIClient.timeout passed below. + # Don't inject timeout for Gemini — its HttpOptions interprets the value + # in milliseconds and would clash with the plugin's own default. + probe_provider_options: dict[str, Any] = dict(body.provider_options or {}) + if body.provider in (GenAIProviderEnum.openai, GenAIProviderEnum.azure_openai): + probe_provider_options.setdefault("timeout", _PROBE_TIMEOUT_SECONDS) + + try: + transient_cfg = GenAIConfig( + provider=body.provider, + api_key=body.api_key, + base_url=body.base_url, + provider_options=probe_provider_options, + # model is required by the schema but irrelevant for listing. + model="probe", + roles=[], + ) + except ValidationError: + logger.exception("GenAI probe: invalid configuration") + return JSONResponse( + status_code=400, + content={"success": False, "message": "Invalid provider configuration"}, + ) + + try: + client = provider_cls( + transient_cfg, + timeout=_PROBE_TIMEOUT_SECONDS, + validate_model=False, + ) + except Exception: + logger.exception("GenAI probe: failed to construct client") + return JSONResponse( + content={ + "success": False, + "message": "Failed to connect to provider", + }, + ) + + try: + models = await asyncio.wait_for( + asyncio.to_thread(client.list_models), + timeout=_PROBE_OUTER_TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + return JSONResponse( + content={"success": False, "message": "Probe timed out"}, + ) + except Exception: + logger.exception("GenAI probe: list_models failed") + return JSONResponse( + content={"success": False, "message": "Provider returned no models"}, + ) + + if not models: + return JSONResponse( + content={ + "success": False, + "message": ( + "No models returned. Check the API key, base URL, and " + "that the provider is reachable." + ), + }, + ) + + return JSONResponse(content={"success": True, "models": models}) + + @router.get("/config", dependencies=[Depends(allow_any_authenticated())]) def config(request: Request): config_obj: FrigateConfig = request.app.frigate_config diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index d9d11fd01..2c37f6ae4 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -2,6 +2,8 @@ from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field +from frigate.config import GenAIProviderEnum + class AppConfigSetBody(BaseModel): requires_restart: int = 1 @@ -10,6 +12,13 @@ class AppConfigSetBody(BaseModel): skip_save: bool = False +class GenAIProbeBody(BaseModel): + provider: GenAIProviderEnum + api_key: Optional[str] = None + base_url: Optional[str] = None + provider_options: Dict[str, Any] = Field(default_factory=dict) + + class AppPutPasswordBody(BaseModel): password: str old_password: Optional[str] = None diff --git a/frigate/config/camera/genai.py b/frigate/config/camera/genai.py index 902c94c42..5b9475572 100644 --- a/frigate/config/camera/genai.py +++ b/frigate/config/camera/genai.py @@ -37,7 +37,7 @@ class GenAIConfig(FrigateBaseModel): description="Base URL for self-hosted or compatible providers (for example an Ollama instance).", ) model: str = Field( - default="gpt-4o", + default="", title="Model", description="The model to use from the provider for generating descriptions or summaries.", ) diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index 864092df5..28a6844d9 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -50,9 +50,15 @@ def register_genai_provider(key: GenAIProviderEnum) -> Callable: class GenAIClient: """Generative AI client for Frigate.""" - def __init__(self, genai_config: GenAIConfig, timeout: int = 120) -> None: + def __init__( + self, + genai_config: GenAIConfig, + timeout: int = 120, + validate_model: bool = True, + ) -> None: self.genai_config: GenAIConfig = genai_config self.timeout = timeout + self.validate_model = validate_model self.provider = self._init_provider() def generate_review_description( diff --git a/frigate/genai/plugins/llama_cpp.py b/frigate/genai/plugins/llama_cpp.py index 830dd6817..2dddf5244 100644 --- a/frigate/genai/plugins/llama_cpp.py +++ b/frigate/genai/plugins/llama_cpp.py @@ -150,6 +150,10 @@ class LlamaCppClient(GenAIClient): else: base_url = base_url.replace("/v1", "") # Strip /v1 if included in base_url + if not self.validate_model: + # Probe path + return base_url + configured_model = self.genai_config.model info = self._get_model_info(base_url, configured_model) diff --git a/frigate/genai/plugins/ollama.py b/frigate/genai/plugins/ollama.py index a6f6d8ddd..0f95dd3f9 100644 --- a/frigate/genai/plugins/ollama.py +++ b/frigate/genai/plugins/ollama.py @@ -118,6 +118,9 @@ class OllamaClient(GenAIClient): timeout=self.timeout, headers=self._auth_headers(), ) + if not self.validate_model: + # Probe path + return client # ensure the model is available locally response = client.show(self.genai_config.model) if response.get("error"): diff --git a/frigate/test/http_api/test_http_app.py b/frigate/test/http_api/test_http_app.py index 2be0e65da..319851526 100644 --- a/frigate/test/http_api/test_http_app.py +++ b/frigate/test/http_api/test_http_app.py @@ -1,5 +1,8 @@ -from unittest.mock import Mock +from unittest.mock import Mock, patch +import frigate.genai +from frigate.config import GenAIProviderEnum +from frigate.genai import GenAIClient from frigate.models import Event, Recordings, ReviewSegment from frigate.stats.emitter import StatsEmitter from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp @@ -71,3 +74,94 @@ class TestHttpApp(BaseTestHttp): assert response.status_code == 200 assert app.frigate_config.cameras["front_door"].objects.track == ["person"] + + #################################################################################################################### + ################################### POST /genai/probe Endpoint ################################################## + #################################################################################################################### + def test_genai_probe_requires_admin(self): + app = super().create_app() + + with AuthTestClient(app) as client: + response = client.post( + "/genai/probe", + json={"provider": "openai"}, + headers={"remote-user": "viewer", "remote-role": "viewer"}, + ) + assert response.status_code == 403 + + def test_genai_probe_returns_models_from_transient_client(self): + class FakeClient(GenAIClient): + def list_models(self): + return ["fake-model-a", "fake-model-b"] + + app = super().create_app() + + with ( + AuthTestClient(app) as client, + patch.dict( + frigate.genai.PROVIDERS, + {GenAIProviderEnum.openai: FakeClient}, + ), + ): + response = client.post( + "/genai/probe", + json={ + "provider": "openai", + "api_key": "sk-test", + "base_url": "https://example.invalid", + }, + ) + assert response.status_code == 200 + assert response.json() == { + "success": True, + "models": ["fake-model-a", "fake-model-b"], + } + + def test_genai_probe_empty_list_is_treated_as_failure(self): + # The plugin's list_models() returns [] on connection failure rather + # than raising. The endpoint should surface that as success=false so + # the UI can show a meaningful error. + class EmptyClient(GenAIClient): + def list_models(self): + return [] + + app = super().create_app() + + with ( + AuthTestClient(app) as client, + patch.dict( + frigate.genai.PROVIDERS, + {GenAIProviderEnum.openai: EmptyClient}, + ), + ): + response = client.post( + "/genai/probe", + json={"provider": "openai"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is False + assert "message" in payload + + def test_genai_probe_handles_provider_failure(self): + class FailingClient(GenAIClient): + def list_models(self): + raise RuntimeError("provider unreachable") + + app = super().create_app() + + with ( + AuthTestClient(app) as client, + patch.dict( + frigate.genai.PROVIDERS, + {GenAIProviderEnum.openai: FailingClient}, + ), + ): + response = client.post( + "/genai/probe", + json={"provider": "openai"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is False + assert "message" in payload diff --git a/web/e2e/specs/replay.spec.ts b/web/e2e/specs/replay.spec.ts index c09abf10b..51a42737f 100644 --- a/web/e2e/specs/replay.spec.ts +++ b/web/e2e/specs/replay.spec.ts @@ -129,8 +129,14 @@ test.describe("Replay — active session @medium", () => { ); await actionGroup.first().click(); - const dialog = frigateApp.page.getByRole("dialog"); - await expect(dialog).toBeVisible({ timeout: 5_000 }); + // On mobile PlatformAwareSheet renders a MobilePage (full-screen panel) + // instead of a Radix Dialog, so assert the panel title heading is visible. + await expect( + frigateApp.page.getByRole("heading", { + level: 2, + name: /^Configuration$/i, + }), + ).toBeVisible({ timeout: 5_000 }); }); test("Objects tab renders with the camera_activity objects list", async ({ diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 9f842b79c..65a343026 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1557,9 +1557,14 @@ "searchPlaceholder": "Search...", "addCustomLabel": "Add custom label...", "genaiModel": { - "placeholder": "Select model…", - "search": "Search models…", - "noModels": "No models available" + "placeholder": "Select or enter a model…", + "search": "Search or enter a model…", + "noModels": "No models available", + "available": "Available models", + "useCustom": "Use \"{{value}}\"", + "refresh": "Refresh models", + "probeFailed": "Failed to probe models", + "fetchedModels": "Successfully fetched model list" } }, "globalConfig": { diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index e61ac8a6a..b4b566fc5 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -1288,11 +1288,6 @@ export function ConfigSection({
- {isOpen ? ( - - ) : ( - - )} {title} {showOverrideIndicator && effectiveLevel === "camera" && @@ -1323,12 +1318,17 @@ export function ConfigSection({ })} )} + {isOpen ? ( + + ) : ( + + )}
-
{sectionContent}
+
{sectionContent}
diff --git a/web/src/components/config-form/sections/section-special-cases.ts b/web/src/components/config-form/sections/section-special-cases.ts index 256c275eb..84bdaae9a 100644 --- a/web/src/components/config-form/sections/section-special-cases.ts +++ b/web/src/components/config-form/sections/section-special-cases.ts @@ -171,7 +171,20 @@ function modifyObjectsSchema( ctx.fullConfig.objects?.track ?? []; - if (track.length === 0) return schema; + // Also promote any label that has a saved filter entry but isn't in + // `track` (e.g. the user toggled an object off but left a customized + // filter in YAML). Without this, RJSF falls back to the additional- + // properties Key/Value editor for those orphans. + const filtersSaved = + (ctx.level !== "global" + ? ctx.fullCameraConfig?.objects?.filters + : undefined) ?? + ctx.fullConfig.objects?.filters ?? + {}; + + if (track.length === 0 && Object.keys(filtersSaved).length === 0) { + return schema; + } const schemaProperties = isJsonObject( (schema as { properties?: unknown }).properties, @@ -199,16 +212,27 @@ function modifyObjectsSchema( ? (filtersSchema as { properties: Record }).properties : {}; - // Promote every tracked label to an explicit property entry so RJSF - // renders it as a normal collapsible (no additionalProperties key/value - // editor UI). Attribute labels get a restricted shape with only - // `min_score`; non-attribute labels get the full FilterConfig. Sorted - // alphabetically so the filter collapsibles match the order of the - // sibling `track` switches. - const sortedTrackedLabels = track - .filter((label): label is string => typeof label === "string") - .slice() - .sort((a, b) => a.localeCompare(b)); + // Promote every tracked label (and any orphaned filter entry) to an + // explicit property entry so RJSF renders it as a normal collapsible + // (no additionalProperties key/value editor UI). Attribute labels get a + // restricted shape with only `min_score`/`min_area`/`max_area`; + // non-attribute labels get the full FilterConfig. Sorted alphabetically + // so the filter collapsibles match the order of the sibling `track` + // switches. + const labelsToPromote = new Set(); + for (const label of track) { + if (typeof label === "string") labelsToPromote.add(label); + } + for (const key of Object.keys(filtersSaved)) { + // Skip attribute labels that aren't tracked — those are hidden + // entirely via hideAttributeFilters; promoting them would surface a + // collapsible we then have to hide separately. + if (attributeSet.has(key) && !labelsToPromote.has(key)) continue; + labelsToPromote.add(key); + } + const sortedTrackedLabels = [...labelsToPromote].sort((a, b) => + a.localeCompare(b), + ); const updatedFilterProperties: Record = { ...existingProperties, }; diff --git a/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx b/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx index 3be8c0fe3..294d06116 100644 --- a/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx +++ b/web/src/components/config-form/theme/widgets/GenAIModelWidget.tsx @@ -4,8 +4,11 @@ import { useState, useMemo, useEffect, useRef } from "react"; import type { WidgetProps } from "@rjsf/utils"; import { useTranslation } from "react-i18next"; import useSWR from "swr"; -import { Check, ChevronsUpDown } from "lucide-react"; +import axios from "axios"; +import { Check, ChevronsUpDown, Plus, RefreshCw } from "lucide-react"; +import { LuCheck } from "react-icons/lu"; import { cn } from "@/lib/utils"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; import { Button } from "@/components/ui/button"; import { Command, @@ -19,9 +22,17 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import type { ConfigFormContext } from "@/types/configForm"; +import type { ConfigFormContext, JsonObject } from "@/types/configForm"; import { getSizedFieldClassName } from "../utils"; +type ProbeResponse = + | { success: true; models: string[] } + | { success: false; message: string }; + +type ProbeStatus = "idle" | "probing" | "success" | "error"; + +const PROBE_SUCCESS_INDICATOR_MS = 3000; + /** * Extract the provider config entry name from the RJSF widget id. * Widget ids look like "root_myProvider_model". @@ -41,6 +52,7 @@ export function GenAIModelWidget(props: WidgetProps) { const { id, value, disabled, readonly, onChange, options, registry } = props; const { t } = useTranslation(["views/settings"]); const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); const fieldClassName = getSizedFieldClassName(options, "sm"); const providerKey = useMemo(() => getProviderKey(id), [id]); @@ -77,78 +89,261 @@ export function GenAIModelWidget(props: WidgetProps) { } }, [configFingerprint, mutateModels]); - const models = useMemo(() => { + const fetchedModels = useMemo(() => { if (!allModels || !providerKey) return []; return allModels[providerKey] ?? []; }, [allModels, providerKey]); + const [probeStatus, setProbeStatus] = useState("idle"); + const [probeError, setProbeError] = useState(null); + const [probedModels, setProbedModels] = useState(null); + const probeSuccessTimerRef = useRef | null>( + null, + ); + + const probing = probeStatus === "probing"; + + // Reset probe results if the provider entry name changes + useEffect(() => { + setProbedModels(null); + setProbeError(null); + setProbeStatus("idle"); + if (probeSuccessTimerRef.current) { + clearTimeout(probeSuccessTimerRef.current); + probeSuccessTimerRef.current = null; + } + }, [providerKey]); + + useEffect(() => { + return () => { + if (probeSuccessTimerRef.current) { + clearTimeout(probeSuccessTimerRef.current); + } + }; + }, []); + + const models = probedModels ?? fetchedModels; + + const trimmedSearch = searchValue.trim(); + const matchesFetched = useMemo( + () => models.some((m) => m.toLowerCase() === trimmedSearch.toLowerCase()), + [models, trimmedSearch], + ); + const showCustomOption = trimmedSearch.length > 0 && !matchesFetched; + + // Read the live form values for this provider so probe sends the user's + // in-flight edits, not the saved config (which may not exist yet). + const formEntry = useMemo(() => { + if (!providerKey) return null; + const formData = formContext?.formData as JsonObject | undefined; + const entry = formData?.[providerKey]; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return null; + } + return entry as JsonObject; + }, [providerKey, formContext?.formData]); + + const formProvider = + typeof formEntry?.provider === "string" ? formEntry.provider : null; + const canProbe = Boolean(formProvider) && !probing; + + const probe = async () => { + if (!formEntry || !formProvider) return; + if (probeSuccessTimerRef.current) { + clearTimeout(probeSuccessTimerRef.current); + probeSuccessTimerRef.current = null; + } + setProbeStatus("probing"); + setProbeError(null); + try { + const res = await axios.post("genai/probe", { + provider: formProvider, + api_key: + typeof formEntry.api_key === "string" ? formEntry.api_key : null, + base_url: + typeof formEntry.base_url === "string" ? formEntry.base_url : null, + provider_options: + formEntry.provider_options && + typeof formEntry.provider_options === "object" && + !Array.isArray(formEntry.provider_options) + ? (formEntry.provider_options as JsonObject) + : {}, + }); + if (res.data.success) { + setProbedModels(res.data.models); + setProbeStatus("success"); + probeSuccessTimerRef.current = setTimeout(() => { + setProbeStatus("idle"); + probeSuccessTimerRef.current = null; + }, PROBE_SUCCESS_INDICATOR_MS); + } else { + setProbedModels([]); + setProbeError(res.data.message); + setProbeStatus("error"); + } + } catch { + setProbedModels(null); + setProbeError( + t("configForm.genaiModel.probeFailed", { + ns: "views/settings", + defaultValue: "Failed to probe models", + }), + ); + setProbeStatus("error"); + } + }; + + const commit = (next: string) => { + onChange(next); + setSearchValue(""); + setOpen(false); + }; + const currentLabel = typeof value === "string" && value ? value : undefined; + const refreshLabel = t("configForm.genaiModel.refresh", { + ns: "views/settings", + defaultValue: "Refresh models", + }); + return ( - - - - - - - - - {models.length > 0 ? ( - - {models.map((model) => ( - { - onChange(model); - setOpen(false); - }} - > - - {model} - - ))} - - ) : ( -
- {t("configForm.genaiModel.noModels", { + +
- )} -
-
-
-
+ + + + + + { + if (e.key === "Enter" && showCustomOption) { + e.preventDefault(); + commit(trimmedSearch); + } + }} + /> + + {showCustomOption && ( + + commit(trimmedSearch)} + > + + {t("configForm.genaiModel.useCustom", { + ns: "views/settings", + value: trimmedSearch, + defaultValue: 'Use "{{value}}"', + })} + + + )} + {models.length > 0 ? ( + + {models.map((model) => ( + commit(model)} + > + + {model} + + ))} + + ) : !showCustomOption ? ( +
+ {t("configForm.genaiModel.noModels", { + ns: "views/settings", + defaultValue: "No models available", + })} +
+ ) : null} +
+
+
+ + + +
+ {probeStatus === "success" && ( + + + {t("configForm.genaiModel.fetchedModels", { + ns: "views/settings", + defaultValue: "Successfully fetched model list", + })} + + )} + {probeStatus === "error" && probeError && ( + {probeError} + )} +
+ ); } diff --git a/web/src/pages/Replay.tsx b/web/src/pages/Replay.tsx index cd73f2ac3..2cf8debfe 100644 --- a/web/src/pages/Replay.tsx +++ b/web/src/pages/Replay.tsx @@ -27,13 +27,7 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from "@/components/ui/dialog"; +import { PlatformAwareSheet } from "@/components/overlay/dialog/PlatformAwareDialog"; import { useCameraActivity } from "@/hooks/use-camera-activity"; import { cn } from "@/lib/utils"; import Heading from "@/components/ui/heading"; @@ -333,15 +327,64 @@ export default function Replay() { )}
- + + + + {t("page.configuration")} + + + } + title={t("page.configuration")} + titleClassName="text-lg font-semibold" + contentClassName="scrollbar-container flex flex-col gap-0 overflow-y-auto px-6 pb-6 sm:max-w-xl md:max-w-2xl xl:max-w-3xl" + content={ + <> +

+ {t("page.configurationDesc")} +

+ {configSchema == null ? ( +
+ +
+ ) : ( +
+ + +
+ )} + + } + open={configDialogOpen} + onOpenChange={setConfigDialogOpen} + /> @@ -644,49 +687,6 @@ export default function Replay() {
- - - - - {t("page.configuration")} - - {t("page.configurationDesc")} - - - {configSchema == null ? ( -
- -
- ) : ( -
- - -
- )} -
-
); } diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index c83dbcc1c..a34e4b7e2 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -162,7 +162,6 @@ const allSettingsViews = [ "cameraLpr", "cameraMqttConfig", "cameraOnvif", - "cameraUi", "cameraTimestampStyle", "cameraManagement", "masksAndZones", @@ -292,9 +291,6 @@ const CameraMqttConfigSettingsPage = createSectionPage("mqtt", "camera", { const CameraOnvifSettingsPage = createSectionPage("onvif", "camera", { showOverrideIndicator: false, }); -const CameraUiSettingsPage = createSectionPage("ui", "camera", { - showOverrideIndicator: false, -}); const CameraTimestampStyleSettingsPage = createSectionPage( "timestamp_style", "camera", @@ -361,7 +357,6 @@ const settingsGroups = [ { key: "cameraLpr", component: CameraLprSettingsPage }, { key: "cameraOnvif", component: CameraOnvifSettingsPage }, { key: "cameraMqttConfig", component: CameraMqttConfigSettingsPage }, - { key: "cameraUi", component: CameraUiSettingsPage }, { key: "cameraTimestampStyle", component: CameraTimestampStyleSettingsPage, @@ -467,7 +462,6 @@ const CAMERA_SELECT_BUTTON_PAGES = [ "cameraLpr", "cameraMqttConfig", "cameraOnvif", - "cameraUi", "cameraTimestampStyle", "masksAndZones", "motionTuner", @@ -495,7 +489,6 @@ const CAMERA_SECTION_MAPPING: Record = { lpr: "cameraLpr", mqtt: "cameraMqttConfig", onvif: "cameraOnvif", - ui: "cameraUi", timestamp_style: "cameraTimestampStyle", };