Miscellaneous fixes (#23258)

* render orphaned filter entries as collapsibles instead of the Key/Value editor

* Symlink for various AI files

* change replay confg dialog to platform aware sheet

* change agents title

* fix test

* tweak collapsible

* remove camera ui section in settings

no point to having it anymore with profiles and camera management settings

* fix admin response cache leak to non-admin users via nginx proxy_cache

* add model fetcher endpoint for genai config ui

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
Josh Hawkins
2026-05-20 09:36:49 -05:00
committed by GitHub
parent 03f4f76b72
commit 8ea46e7c6c
19 changed files with 1111 additions and 596 deletions

View File

@@ -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 <div>{t("camera_not_found")}</div>;
}
```
- **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
<div>Camera not found</div> # ❌ 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();
<div>{t("camera_not_found")}</div> # ✅ 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

1
.github/copilot-instructions.md vendored Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

439
AGENTS.md Normal file
View File

@@ -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 <div>{t("camera_not_found")}</div>;
}
```
- **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
<div>Camera not found</div> # ❌ 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();
<div>{t("camera_not_found")}</div> # ✅ 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

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -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;

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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.",
)

View File

@@ -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(

View File

@@ -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)

View File

@@ -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"):

View File

@@ -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

View File

@@ -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 ({

View File

@@ -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": {

View File

@@ -1288,11 +1288,6 @@ export function ConfigSection({
<CollapsibleTrigger asChild>
<div className="flex cursor-pointer items-center justify-between">
<div className="flex items-center gap-3">
{isOpen ? (
<LuChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<LuChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<Heading as="h4">{title}</Heading>
{showOverrideIndicator &&
effectiveLevel === "camera" &&
@@ -1323,12 +1318,17 @@ export function ConfigSection({
})}
</Badge>
)}
{isOpen ? (
<LuChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<LuChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pl-7">{sectionContent}</div>
<div className="pl-0">{sectionContent}</div>
</CollapsibleContent>
</div>
</Collapsible>

View File

@@ -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<string, RJSFSchema> }).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<string>();
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<string, RJSFSchema> = {
...existingProperties,
};

View File

@@ -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<ProbeStatus>("idle");
const [probeError, setProbeError] = useState<string | null>(null);
const [probedModels, setProbedModels] = useState<string[] | null>(null);
const probeSuccessTimerRef = useRef<ReturnType<typeof setTimeout> | 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<JsonObject | null>(() => {
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<ProbeResponse>("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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || readonly}
className={cn(
"justify-between font-normal",
!currentLabel && "text-muted-foreground",
fieldClassName,
)}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearchValue("");
}}
>
{currentLabel ??
t("configForm.genaiModel.placeholder", {
ns: "views/settings",
defaultValue: "Select model…",
})}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput
placeholder={t("configForm.genaiModel.search", {
ns: "views/settings",
defaultValue: "Search models…",
})}
/>
<CommandList>
{models.length > 0 ? (
<CommandGroup>
{models.map((model) => (
<CommandItem
key={model}
value={model}
onSelect={() => {
onChange(model);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === model ? "opacity-100" : "opacity-0",
)}
/>
{model}
</CommandItem>
))}
</CommandGroup>
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("configForm.genaiModel.noModels", {
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || readonly}
className={cn(
"justify-between font-normal",
!currentLabel && "text-muted-foreground",
fieldClassName,
)}
>
{currentLabel ??
t("configForm.genaiModel.placeholder", {
ns: "views/settings",
defaultValue: "No models available",
defaultValue: "Select or enter a model…",
})}
</div>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput
placeholder={t("configForm.genaiModel.search", {
ns: "views/settings",
defaultValue: "Search or enter a model…",
})}
value={searchValue}
onValueChange={setSearchValue}
onKeyDown={(e) => {
if (e.key === "Enter" && showCustomOption) {
e.preventDefault();
commit(trimmedSearch);
}
}}
/>
<CommandList>
{showCustomOption && (
<CommandGroup>
<CommandItem
value={trimmedSearch}
onSelect={() => commit(trimmedSearch)}
>
<Plus className="mr-2 h-4 w-4" />
{t("configForm.genaiModel.useCustom", {
ns: "views/settings",
value: trimmedSearch,
defaultValue: 'Use "{{value}}"',
})}
</CommandItem>
</CommandGroup>
)}
{models.length > 0 ? (
<CommandGroup
heading={t("configForm.genaiModel.available", {
ns: "views/settings",
defaultValue: "Available models",
})}
>
{models.map((model) => (
<CommandItem
key={model}
value={model}
onSelect={() => commit(model)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === model ? "opacity-100" : "opacity-0",
)}
/>
{model}
</CommandItem>
))}
</CommandGroup>
) : !showCustomOption ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("configForm.genaiModel.noModels", {
ns: "views/settings",
defaultValue: "No models available",
})}
</div>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
disabled={!canProbe || disabled || readonly}
onClick={probe}
title={refreshLabel}
aria-label={refreshLabel}
>
{probing ? (
<ActivityIndicator className="h-4 w-4" size={16} />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
<div
aria-live="polite"
className={cn(
"flex items-center justify-start gap-1 text-xs transition-opacity duration-200",
probeStatus === "idle" || probeStatus === "probing"
? "opacity-0"
: "opacity-100",
)}
>
{probeStatus === "success" && (
<span className="flex items-center gap-1 text-success">
<LuCheck className="size-3.5" />
{t("configForm.genaiModel.fetchedModels", {
ns: "views/settings",
defaultValue: "Successfully fetched model list",
})}
</span>
)}
{probeStatus === "error" && probeError && (
<span className="text-destructive">{probeError}</span>
)}
</div>
</div>
);
}

View File

@@ -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() {
)}
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
onClick={() => setConfigDialogOpen(true)}
>
<LuSettings className="size-4" />
<span className="hidden md:inline">{t("page.configuration")}</span>
</Button>
<PlatformAwareSheet
trigger={
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<LuSettings className="size-4" />
<span className="hidden md:inline">
{t("page.configuration")}
</span>
</Button>
}
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={
<>
<p className="mb-5 text-sm text-muted-foreground">
{t("page.configurationDesc")}
</p>
{configSchema == null ? (
<div className="flex h-40 items-center justify-center">
<ActivityIndicator />
</div>
) : (
<div className="space-y-6">
<ConfigSectionTemplate
sectionKey="motion"
level="replay"
cameraName={status.replay_camera ?? undefined}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
<ConfigSectionTemplate
sectionKey="objects"
level="replay"
cameraName={status.replay_camera ?? undefined}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
</div>
)}
</>
}
open={configDialogOpen}
onOpenChange={setConfigDialogOpen}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
@@ -644,49 +687,6 @@ export default function Replay() {
</Tabs>
</div>
</div>
<Dialog open={configDialogOpen} onOpenChange={setConfigDialogOpen}>
<DialogContent className="scrollbar-container max-h-[90dvh] overflow-y-auto sm:max-w-xl md:max-w-3xl lg:max-w-4xl">
<DialogHeader>
<DialogTitle>{t("page.configuration")}</DialogTitle>
<DialogDescription className="mb-5">
{t("page.configurationDesc")}
</DialogDescription>
</DialogHeader>
{configSchema == null ? (
<div className="flex h-40 items-center justify-center">
<ActivityIndicator />
</div>
) : (
<div className="space-y-6">
<ConfigSectionTemplate
sectionKey="motion"
level="replay"
cameraName={status.replay_camera ?? undefined}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
<ConfigSectionTemplate
sectionKey="objects"
level="replay"
cameraName={status.replay_camera ?? undefined}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -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<string, SettingsType> = {
lpr: "cameraLpr",
mqtt: "cameraMqttConfig",
onvif: "cameraOnvif",
ui: "cameraUi",
timestamp_style: "cameraTimestampStyle",
};