mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-10 23:27:07 -04:00
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:
439
.github/copilot-instructions.md
vendored
439
.github/copilot-instructions.md
vendored
@@ -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
1
.github/copilot-instructions.md
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
439
AGENTS.md
Normal file
439
AGENTS.md
Normal 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
|
||||
@@ -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;
|
||||
|
||||
74
docs/static/frigate-api.yaml
vendored
74
docs/static/frigate-api.yaml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user