use react-jsonschema-form for UI config

This commit is contained in:
Josh Hawkins 2026-01-23 08:23:52 -06:00
parent eeefbf2bb5
commit 68c74fef05
48 changed files with 4059 additions and 11 deletions

View File

@ -0,0 +1,488 @@
---
task: UI Configuration using react-jsonschema-form
slug: ui-config-rjsf
created: 2026-01-21
status: planning
---
# UI Configuration using react-jsonschema-form
## Overview
Implement a comprehensive configuration UI for Frigate NVR using react-jsonschema-form (RJSF), driven by the existing Pydantic configuration schema. The UI should allow users to configure Frigate through a web interface instead of manually editing YAML files, with proper validation, reusable components, and clear visual indicators for global vs camera-level settings.
## Research Findings
### Key Components
| Component | Location | Purpose |
| ---------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------- |
| FrigateConfig | [frigate/config/config.py](frigate/config/config.py#L297-L412) | Root Pydantic model defining complete Frigate configuration |
| CameraConfig | [frigate/config/camera/camera.py](frigate/config/camera/camera.py#L49-L130) | Camera-level configuration model |
| FrigateBaseModel | [frigate/config/base.py](frigate/config/base.py) | Base model with `extra="forbid"` validation |
| Config API | [frigate/api/app.py](frigate/api/app.py#L367-L456) | PUT /config/set endpoint for saving config |
| Schema Endpoint | [frigate/api/app.py](frigate/api/app.py#L73-L77) | GET /config/schema.json - exposes Pydantic schema |
| Settings Page | [web/src/pages/Settings.tsx](web/src/pages/Settings.tsx) | Main settings page with sidebar navigation |
| Form Components | [web/src/components/ui/form.tsx](web/src/components/ui/form.tsx) | Existing react-hook-form based form components |
### Architecture
The Frigate configuration system is structured as follows:
1. **Pydantic Models**: All configuration is defined using Pydantic v2 models in `frigate/config/`. The `FrigateConfig` class is the root model.
2. **Schema Generation**: Pydantic automatically generates JSON Schema via `model_json_schema()`. The schema is already exposed at `/api/config/schema.json` and used by the ConfigEditor (monaco-yaml) for validation.
3. **Config API**: The `/config/set` endpoint accepts configuration updates in two formats:
- Query string parameters (e.g., `?cameras.front.detect.enabled=True`)
- JSON body via `config_data` field in `AppConfigSetBody`
The JSON body is preferred.
4. **Frontend Stack**:
- React 18 with TypeScript
- Radix UI primitives + Tailwind CSS (shadcn/ui patterns)
- react-hook-form + zod for form validation
- SWR for data fetching
- react-i18next for translations
- axios for API calls
### Data Flow
1. **Config Loading**: `useSWR("config")` fetches current config from `/api/config`
2. **Schema Loading**: JSON Schema available at `/api/config/schema.json`
3. **Config Updates**: PUT to `/config/set` with JSON body
4. **Real-time Updates**: Some settings support `requires_restart: 0` for live updates via WebSocket pub/sub
### Reusable Config Sections (Global + Camera Level)
Based on schema analysis, these sections appear at BOTH global and camera levels using the **same** Pydantic model:
| Section | Model | Notes |
| --------------- | -------------------- | ------------------------------------------------------------------- |
| audio | AudioConfig | Full model reuse |
| detect | DetectConfig | Full model reuse, nested StationaryConfig |
| live | CameraLiveConfig | Full model reuse |
| motion | MotionConfig | Full model reuse |
| notifications | NotificationConfig | Full model reuse |
| objects | ObjectConfig | Full model reuse, nested FilterConfig, GenAIObjectConfig |
| record | RecordConfig | Full model reuse, nested EventsConfig, RetainConfig |
| review | ReviewConfig | Full model reuse, nested ReviewAlertsConfig, ReviewDetectionsConfig |
| snapshots | SnapshotsConfig | Full model reuse, nested SnapshotsRetainConfig |
| timestamp_style | TimestampStyleConfig | Full model reuse, nested ColorConfig |
**Different models at global vs camera level** (require conditional handling):
| Section | Global Model | Camera Model | Notes |
| ------------------- | ----------------------------- | ----------------------------------- | -------------------- |
| audio_transcription | AudioTranscriptionConfig | CameraAudioTranscriptionConfig | Camera has subset |
| birdseye | BirdseyeConfig | BirdseyeCameraConfig | Different fields |
| face_recognition | FaceRecognitionConfig | CameraFaceRecognitionConfig | Camera has subset |
| ffmpeg | FfmpegConfig | CameraFfmpegConfig | Camera adds inputs[] |
| lpr | LicensePlateRecognitionConfig | CameraLicensePlateRecognitionConfig | Camera has subset |
| semantic_search | SemanticSearchConfig | CameraSemanticSearchConfig | Completely different |
### Special Field Types Requiring Custom Widgets
| Type | Examples | Widget Needed |
| --------------------- | --------------------------------------------------- | ------------------------------ |
| Enums | BirdseyeModeEnum, RetainModeEnum, RecordQualityEnum | Select dropdown |
| list[str] | objects.track, zones[], required_zones | Tag input / Multi-select |
| Union[str, list[str]] | mask fields | Text or array input |
| dict[str, T] | zones, objects.filters | Key-value editor / Nested form |
| ColorConfig | timestamp_style.color (RGB) | Color picker |
| Coordinates/Masks | zone.coordinates | Polygon editor (existing) |
| Password fields | mqtt.password | Password input with show/hide |
### Current UI Patterns
1. **Settings Navigation**: [web/src/pages/Settings.tsx](web/src/pages/Settings.tsx#L68-L114) uses a sidebar with grouped sections
2. **Camera Selection**: Many views accept `selectedCamera` prop for per-camera settings
3. **Form Pattern**: Uses react-hook-form with zod schemas (see [CameraReviewSettingsView.tsx](web/src/views/settings/CameraReviewSettingsView.tsx#L113-L124))
4. **Save Pattern**: Axios PUT to `config/set?key=value` with `requires_restart` flag
5. **Translations**: All strings in `web/public/locales/{lang}/views/` JSON files
### Dependencies
**Internal**:
- Existing form components: Form, FormField, FormItem, FormLabel, FormControl, FormMessage
- UI primitives: Switch, Select, Input, Checkbox, Slider, Tabs
- Existing hooks: useSWR, useTranslation, useOptimisticState
**External (to add)**:
- @rjsf/core (react-jsonschema-form core)
- @rjsf/utils (utilities)
- @rjsf/validator-ajv8 (JSON Schema validation)
### Configuration
The `/config/set` API expects:
```typescript
interface AppConfigSetBody {
requires_restart: number; // 0 = live update, 1 = needs restart
update_topic?: string; // For pub/sub notification
config_data?: Record<string, any>; // Bulk config updates
}
```
Query string format: `?key.path.to.field=value` (e.g., `?cameras.front.detect.enabled=True`)
### Tests
| Test File | Coverage |
| ---------------------------------------------------- | ------------------------------------- |
| No existing React component tests found | RJSF forms would benefit from testing |
| Pydantic validation tests implicit in config loading | Schema validation ensures correctness |
## Implementation Plan
### Goal
Users can configure all user-facing Frigate settings through a form-based UI with validation, clear global vs camera-level distinction, and proper handling of advanced settings.
### Current State Analysis
- Schema already exposed at `/api/config/schema.json`
- Settings page structure exists with sidebar navigation
- Form components exist (react-hook-form based)
- No react-jsonschema-form currently installed
- Config set API supports both query strings and JSON body
### What We're NOT Doing
- Replacing the raw YAML ConfigEditor (remains as advanced option)
- Changing the Pydantic models structure
- Modifying the config/set API endpoint
- Auto-generating translations (manual translation required)
### Prerequisites
- [x] Install @rjsf/core, @rjsf/utils, @rjsf/validator-ajv8
- [x] Create exclusion list and advanced settings list JSON files
---
## Phases
| # | Phase | Status | Plan | Notes |
| --- | -------------------------------- | ------- | ---- | ------------------------------------------------------------ |
| 1 | Schema Pipeline & RJSF Setup | ✅ Done | — | Install deps, create schema transformer, set up RJSF theme |
| 2 | Core Reusable Section Components | ✅ Done | — | Create shared components for detect, record, snapshots, etc. |
| 3 | Global Configuration View | ✅ Done | — | Build main config sections (mqtt, auth, database, etc.) |
| 4 | Camera Configuration with Tabs | ✅ Done | — | Multi-camera tabs, override indicators, section reuse |
| 5 | Advanced Settings & Exclusions | ✅ Done | — | Progressive disclosure, contributor-editable lists |
| 6 | Validation & Error Handling | ✅ Done | — | Inline errors, save blocking, API integration |
| 7 | Integration & Polish | ✅ Done | — | Settings page integration, translations, documentation |
**Status:** ⬜ Not Started → 📋 Planned → 🔄 In Progress → ✅ Done
---
## Phase Details
### Phase 1: Schema Pipeline & RJSF Setup
**Overview**: Install react-jsonschema-form dependencies, create a schema transformation layer to convert Pydantic JSON Schema to RJSF-compatible format with UI customizations.
**Changes Required**:
1. **Install Dependencies**
- Add to package.json: @rjsf/core, @rjsf/utils, @rjsf/validator-ajv8
2. **Create Schema Transformer**
- File: `web/src/lib/config-schema/`
- Transform Pydantic schema to RJSF uiSchema
- Handle nested $defs/references
- Apply field ordering
3. **Create Custom RJSF Theme**
- File: `web/src/components/config-form/theme/`
- Map RJSF templates to existing shadcn/ui components
- Custom widgets for special types (color, coordinates, etc.)
**Success Criteria**:
- RJSF renders basic form from schema
- Existing UI component styling preserved
- Schema fetching from /api/config/schema.json works
### Phase 2: Core Reusable Section Components
**Overview**: Create composable section components for config areas that appear at both global and camera levels.
**Changes Required**:
1. **Section Component Architecture**
- File: `web/src/components/config-form/sections/`
- Create: DetectSection, RecordSection, SnapshotsSection, MotionSection, ObjectsSection, ReviewSection, AudioSection, NotificationsSection, LiveSection, TimestampSection
2. **Each Section Component**:
- Accepts `level: "global" | "camera"` prop
- Accepts `cameraName?: string` for camera context
- Accepts `showOverrideIndicator?: boolean`
- Uses shared RJSF form with section-specific uiSchema
3. **Override Detection Hook**
- File: `web/src/hooks/use-config-override.ts`
- Compare camera value vs global default
- Return override status for visual indicators
4. **Field Ordering and Layout Customization**
**Requirement**: Field ordering and layout within each section must be easily customizable by contributors without requiring deep knowledge of RJSF internals.
**Implementation Approach**:
- Each reusable section component (DetectSection, RecordSection, etc.) should define its own field ordering and layout configuration
- This can be accomplished as a TypeScript constant within the section component file itself
- The configuration should specify:
- Field display order
- Field grouping (which fields appear together)
- Layout hints (e.g., multiple fields per row, nested groupings)
- Any section-specific uiSchema customizations
**Example Structure**:
```typescript
// In DetectSection.tsx or DetectSection.config.ts
export const detectSectionConfig = {
fieldOrder: [
"enabled",
"fps",
"width",
"height",
"max_disappeared",
"stationary",
],
fieldGroups: {
resolution: ["width", "height"],
performance: ["fps", "max_disappeared"],
},
// ... other layout hints
};
```
**Success Criteria**:
- Contributors can reorder fields by editing a clear configuration structure
- No need to modify RJSF internals or complex uiSchema objects directly
- Layout changes are localized to single files per section
**Success Criteria**:
- DetectSection renders identically at global and camera level
- Override indicators show when camera differs from global
- Adding new fields requires editing only section definition
### Phase 3: Global Configuration View
**Overview**: Build the global configuration form for non-camera settings.
**Changes Required**:
1. **Global Config View**
- File: `web/src/views/settings/GlobalConfigView.tsx`
- Sections: MQTT, Auth, Database, Telemetry, TLS, Proxy, Networking, UI, Detectors, Model, GenAI, Classification, Birdseye
2. **Per-Section Subforms**
- Each section as collapsible card
- Progressive disclosure for advanced fields
- Individual save buttons per section OR unified save
3. **Translations**
- File: `web/public/locales/en/views/settings.json`
- Add keys for all config field labels/descriptions
**Success Criteria**:
- All global-only settings configurable
- Proper field grouping and labels
- Validation errors inline
### Phase 4: Camera Configuration with Tabs
**Overview**: Create per-camera configuration with tab navigation and override indicators.
**Changes Required**:
1. **Camera Config View**
- File: `web/src/views/settings/CameraConfigView.tsx`
- Tab per camera
- Uses reusable section components
2. **Override Visual Indicators**
- Badge/icon when field overrides global
- "Reset to global default" action
- Color coding (e.g., highlighted border)
3. **Camera-Specific Sections**
- FFmpeg inputs configuration
- Masks and Zones (link to existing editor)
- ONVIF settings
**Success Criteria**:
- Switch between cameras via tabs
- Clear visual distinction for overridden settings
- Reset to global default works
### Phase 5: Advanced Settings & Exclusions
**Overview**: Implement progressive disclosure and maintainable exclusion lists.
**Changes Required**:
1. **Exclusion System**
- Simple consts in the component for field names
- Filter schema before rendering
- Document exclusion format for contributors
2. **Advanced Fields Toggle**
- "Show Advanced Settings" switch per section
- Simple consts in the component for advanced field names
- Default collapsed state
**Success Criteria**:
- Excluded fields never shown in UI
- Advanced fields hidden by default
### Phase 6: Validation & Error Handling
**Overview**: Ensure robust validation and user-friendly error messages.
**Changes Required**:
1. **Client-Side Validation**
- ajv8 validator with Pydantic schema
- Custom error messages for common issues
- Real-time validation on blur
2. **Server-Side Validation**
- Handle 400 responses from /config/set
- Parse Pydantic validation errors
- Map to form fields
3. **Save Blocking**
- Disable save button when invalid
- Show error count badge
- Scroll to first error on submit attempt
**Success Criteria**:
- Invalid forms cannot be saved
- Errors shown inline next to fields
- Clear error messages (not technical schema errors)
### Phase 7: Integration & Polish
**Overview**: Integrate into settings page, finalize translations, and document.
**Changes Required**:
1. **Settings Page Integration**
- Add new views to settingsGroups in Settings.tsx
- Sidebar navigation updates
- Route configuration
2. **Translations**
- All field labels and descriptions
- Error messages
- Section headers
3. **Documentation**
- User-facing docs for UI configuration
4. **Testing**
- Basic render tests for form components
- Validation behavior tests
- Save/cancel flow tests
**Success Criteria**:
- Seamless navigation from settings page
- All strings translated
- Documentation complete
---
## Testing Strategy
### Project Maturity Level
Active Development - Frigate has extensive test infrastructure but limited frontend tests.
### Unit Tests
- Schema transformer functions
- Override detection hook
- Custom widgets
- Coverage target: 70% for new components
### Integration/Manual Tests
- Full form render with live schema
- Save/validation flow end-to-end
- Camera tab switching
- Override indicator accuracy
- Mobile responsiveness
---
## Rollback Plan
1. All changes are additive - existing ConfigEditor remains functional
2. New views can be feature-flagged if needed
3. No database migrations required
4. No backend changes required (uses existing API)
---
## Component Hierarchy
```
web/src/
├── components/
│ └── config-form/
│ ├── theme/
│ │ ├── index.ts # RJSF theme export
│ │ ├── templates/ # Base templates (ObjectFieldTemplate, etc.)
│ │ └── widgets/ # Custom widgets (ColorWidget, TagsWidget, etc.)
│ ├── sections/
│ │ ├── DetectSection.tsx # Reusable for global + camera
│ │ ├── RecordSection.tsx
│ │ ├── SnapshotsSection.tsx
│ │ ├── MotionSection.tsx
│ │ ├── ObjectsSection.tsx
│ │ ├── ReviewSection.tsx
│ │ ├── AudioSection.tsx
│ │ ├── NotificationsSection.tsx
│ │ ├── LiveSection.tsx
│ │ └── TimestampSection.tsx
│ └── ConfigForm.tsx # Main form wrapper
├── lib/
│ └── config-schema/
│ ├── index.ts # Schema utilities
│ ├── transformer.ts # Pydantic -> RJSF schema
├── hooks/
│ └── use-config-override.ts # Override detection
└── views/
└── settings/
├── GlobalConfigView.tsx # Global settings form
└── CameraConfigView.tsx # Per-camera tabs form
```
---
## Key Design Decisions
1. **RJSF over custom forms**: Leverage schema-driven forms for maintainability and automatic updates when Pydantic models change.
2. **Reusable sections via composition**: Same component renders at global and camera level, with props controlling context and override indicators.
3. **Existing UI primitives**: Custom RJSF theme wraps existing shadcn/ui components for visual consistency.
4. **Incremental adoption**: Existing settings views remain, new RJSF views added alongside.

207
web/package-lock.json generated
View File

@ -32,6 +32,9 @@
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.2.8",
"@rjsf/core": "^6.2.5",
"@rjsf/utils": "^6.2.5",
"@rjsf/validator-ajv8": "^6.2.5",
"apexcharts": "^3.52.0",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.1",
@ -3301,6 +3304,86 @@
"node": ">=14.0.0"
}
},
"node_modules/@rjsf/core": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.2.5.tgz",
"integrity": "sha512-k/2aAKj9IY7JBcnPrYv7frgHkfK0KsS7h8PgPW14GJREh+X5EX/icrypcQu5ge/Ggbwi+90plJll07YiRV/lFg==",
"license": "Apache-2.0",
"dependencies": {
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"markdown-to-jsx": "^8.0.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@rjsf/utils": "^6.2.x",
"react": ">=18"
}
},
"node_modules/@rjsf/utils": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.2.5.tgz",
"integrity": "sha512-29SvRuY3gKyAHUUnIiJiAF/mTnokgrE7XqUXMj+CZK+sGcmAegwhlnQMJgLQciTodMwTwOaDyV1Fxc47VKTHFw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@x0k/json-schema-merge": "^1.0.2",
"fast-uri": "^3.1.0",
"jsonpointer": "^5.0.1",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"react-is": "^18.3.1"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"react": ">=18"
}
},
"node_modules/@rjsf/validator-ajv8": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.2.5.tgz",
"integrity": "sha512-+yLhFRuT2aY91KiUujhUKg8SyTBrUuQP3QAFINeGi+RljA3S+NQN56oeCaNdz9X+35+Sdy6jqmxy/0Q2K+K9vQ==",
"license": "Apache-2.0",
"dependencies": {
"ajv": "^8.17.1",
"ajv-formats": "^2.1.1",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@rjsf/utils": "^6.2.x"
}
},
"node_modules/@rjsf/validator-ajv8/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@rjsf/validator-ajv8/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.9.tgz",
@ -3859,6 +3942,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.12.tgz",
@ -3872,6 +3961,7 @@
"integrity": "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~5.26.4"
}
@ -3886,6 +3976,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
"integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@ -3896,6 +3987,7 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz",
"integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==",
"devOptional": true,
"peer": true,
"dependencies": {
"@types/react": "*"
}
@ -4046,6 +4138,7 @@
"integrity": "sha512-dm/J2UDY3oV3TKius2OUZIFHsomQmpHtsV0FTh1WO8EKgHLQ1QCADUqscPgTpU+ih1e21FQSRjXckHn3txn6kQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.12.0",
"@typescript-eslint/types": "7.12.0",
@ -4313,6 +4406,15 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@x0k/json-schema-merge": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.2.tgz",
"integrity": "sha512-1734qiJHNX3+cJGDMMw2yz7R+7kpbAtl5NdPs1c/0gO5kYT6s4dMbLXiIfpZNsOYhGZI3aH7FWrj4Zxz7epXNg==",
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.15"
}
},
"node_modules/@yr/monotone-cubic-spline": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz",
@ -4323,6 +4425,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -4370,6 +4473,45 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/ansi-escapes": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@ -4445,6 +4587,7 @@
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.52.0.tgz",
"integrity": "sha512-7dg0ADKs8AA89iYMZMe2sFDG0XK5PfqllKV9N+i3hKHm3vEtdhwz8AlXGm+/b0nJ6jKiaXsqci5LfVxNhtB+dA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@yr/monotone-cubic-spline": "^1.0.3",
"svg.draggable.js": "^2.2.2",
@ -4645,6 +4788,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001646",
"electron-to-chromium": "^1.5.4",
@ -5394,6 +5538,7 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@ -5664,7 +5809,8 @@
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.2.0.tgz",
"integrity": "sha512-rf2GIX8rab9E6ZZN0Uhz05746qu2KrDje9IfFyHzjwxLwhvGjUt6y9+uaY1Sf+B0OPSa3sgas7BE2hWZCtopTA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/embla-carousel-react": {
"version": "8.2.0",
@ -5827,6 +5973,7 @@
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@ -5882,6 +6029,7 @@
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"dev": true,
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@ -6121,7 +6269,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-diff": {
@ -6175,6 +6322,22 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fastq": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
@ -6672,6 +6835,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.23.2"
},
@ -7138,6 +7302,15 @@
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz",
"integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w=="
},
"node_modules/jsonpointer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz",
"integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -7166,7 +7339,8 @@
"url": "https://github.com/sponsors/lavrton"
}
],
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/levn": {
"version": "0.4.1",
@ -8002,6 +8176,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
@ -8136,6 +8311,7 @@
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -8330,6 +8506,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -8396,6 +8573,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@ -8465,6 +8643,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.52.1.tgz",
"integrity": "sha512-uNKIhaoICJ5KQALYZ4TOaOLElyM+xipord+Ha3crEFhTntdLvWZqVY49Wqd/0GiVCA/f9NjemLeiNPjG7Hpurg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.22.0"
},
@ -8508,10 +8687,10 @@
}
},
"node_modules/react-is": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
"dev": true
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-konva": {
"version": "18.2.10",
@ -8806,6 +8985,15 @@
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@ -9082,6 +9270,7 @@
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
@ -9523,6 +9712,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz",
"integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@ -9853,6 +10043,7 @@
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -10083,6 +10274,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -10220,6 +10412,7 @@
"integrity": "sha512-IP7gPK3LS3Fvn44x30X1dM9vtawm0aesAa2yBIZ9vQf+qB69NXC5776+Qmcr7ohUXIQuLhk7xQR0aSUIDPqavg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "3.0.7",
"@vitest/mocker": "3.0.7",

View File

@ -38,6 +38,9 @@
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.2.8",
"@rjsf/core": "^6.2.5",
"@rjsf/utils": "^6.2.5",
"@rjsf/validator-ajv8": "^6.2.5",
"apexcharts": "^3.52.0",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.1",

View File

@ -9,11 +9,16 @@
"motionTuner": "Motion Tuner - Frigate",
"object": "Debug - Frigate",
"general": "UI Settings - Frigate",
"globalConfig": "Global Configuration - Frigate",
"cameraConfig": "Camera Configuration - Frigate",
"frigatePlus": "Frigate+ Settings - Frigate",
"notifications": "Notification Settings - Frigate"
"notifications": "Notification Settings - Frigate",
"maintenance": "Maintenance - Frigate"
},
"menu": {
"ui": "UI",
"globalConfig": "Global Config",
"cameraConfig": "Camera Config",
"enrichments": "Enrichments",
"cameraManagement": "Management",
"cameraReview": "Review",
@ -24,7 +29,8 @@
"users": "Users",
"roles": "Roles",
"notifications": "Notifications",
"frigateplus": "Frigate+"
"frigateplus": "Frigate+",
"maintenance": "Maintenance"
},
"dialog": {
"unsavedChanges": {
@ -1115,5 +1121,155 @@
"exports": "Exports",
"recordings": "Recordings"
}
},
"configForm": {
"showAdvanced": "Show Advanced Settings",
"tabs": {
"sharedDefaults": "Shared Defaults",
"system": "System",
"integrations": "Integrations"
},
"sections": {
"detect": "Detection",
"record": "Recording",
"snapshots": "Snapshots",
"motion": "Motion",
"objects": "Objects",
"review": "Review",
"audio": "Audio",
"notifications": "Notifications",
"live": "Live View",
"timestamp_style": "Timestamps",
"mqtt": "MQTT",
"database": "Database",
"telemetry": "Telemetry",
"auth": "Authentication",
"tls": "TLS",
"proxy": "Proxy",
"go2rtc": "go2rtc",
"ffmpeg": "FFmpeg",
"detectors": "Detectors",
"model": "Model",
"semantic_search": "Semantic Search",
"genai": "GenAI",
"face_recognition": "Face Recognition",
"lpr": "License Plate Recognition",
"birdseye": "Birdseye"
},
"detect": {
"title": "Detection Settings",
"toast": {
"success": "Detection settings saved successfully",
"error": "Failed to save detection settings",
"resetSuccess": "Detection settings reset to global defaults",
"resetError": "Failed to reset detection settings"
}
},
"record": {
"title": "Recording Settings",
"toast": {
"success": "Recording settings saved successfully",
"error": "Failed to save recording settings",
"resetSuccess": "Recording settings reset to global defaults",
"resetError": "Failed to reset recording settings"
}
},
"snapshots": {
"title": "Snapshot Settings",
"toast": {
"success": "Snapshot settings saved successfully",
"error": "Failed to save snapshot settings",
"resetSuccess": "Snapshot settings reset to global defaults",
"resetError": "Failed to reset snapshot settings"
}
},
"motion": {
"title": "Motion Settings",
"toast": {
"success": "Motion settings saved successfully",
"error": "Failed to save motion settings",
"resetSuccess": "Motion settings reset to global defaults",
"resetError": "Failed to reset motion settings"
}
},
"objects": {
"title": "Object Settings",
"toast": {
"success": "Object settings saved successfully",
"error": "Failed to save object settings",
"resetSuccess": "Object settings reset to global defaults",
"resetError": "Failed to reset object settings"
}
},
"review": {
"title": "Review Settings",
"toast": {
"success": "Review settings saved successfully",
"error": "Failed to save review settings",
"resetSuccess": "Review settings reset to global defaults",
"resetError": "Failed to reset review settings"
}
},
"audio": {
"title": "Audio Settings",
"toast": {
"success": "Audio settings saved successfully",
"error": "Failed to save audio settings",
"resetSuccess": "Audio settings reset to global defaults",
"resetError": "Failed to reset audio settings"
}
},
"notifications": {
"title": "Notification Settings",
"toast": {
"success": "Notification settings saved successfully",
"error": "Failed to save notification settings",
"resetSuccess": "Notification settings reset to global defaults",
"resetError": "Failed to reset notification settings"
}
},
"live": {
"title": "Live View Settings",
"toast": {
"success": "Live view settings saved successfully",
"error": "Failed to save live view settings",
"resetSuccess": "Live view settings reset to global defaults",
"resetError": "Failed to reset live view settings"
}
},
"timestamp_style": {
"title": "Timestamp Settings",
"toast": {
"success": "Timestamp settings saved successfully",
"error": "Failed to save timestamp settings",
"resetSuccess": "Timestamp settings reset to global defaults",
"resetError": "Failed to reset timestamp settings"
}
}
},
"globalConfig": {
"title": "Global Configuration",
"description": "Configure global settings that apply to all cameras unless overridden.",
"toast": {
"success": "Global settings saved successfully",
"error": "Failed to save global settings",
"validationError": "Validation failed"
}
},
"cameraConfig": {
"title": "Camera Configuration",
"description": "Configure settings for individual cameras. Settings override global defaults.",
"overriddenBadge": "Overridden",
"resetToGlobal": "Reset to Global",
"toast": {
"success": "Camera settings saved successfully",
"error": "Failed to save camera settings"
}
},
"common": {
"overridden": "Overridden",
"resetToGlobal": "Reset to Global",
"save": "Save",
"cancel": "Cancel"
}
}

View File

@ -0,0 +1,157 @@
// ConfigForm - Main RJSF form wrapper component
import Form from "@rjsf/core";
import validator from "@rjsf/validator-ajv8";
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
import type { IChangeEvent } from "@rjsf/core";
import { frigateTheme } from "./theme";
import { transformSchema } from "@/lib/config-schema";
import { createErrorTransformer } from "@/lib/config-schema/errorMessages";
import { useMemo, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
export interface ConfigFormProps {
/** JSON Schema for the form */
schema: RJSFSchema;
/** Current form data */
formData?: Record<string, unknown>;
/** Called when form data changes */
onChange?: (data: Record<string, unknown>) => void;
/** Called when form is submitted */
onSubmit?: (data: Record<string, unknown>) => void;
/** Called when form has errors on submit */
onError?: (errors: unknown[]) => void;
/** Additional uiSchema overrides */
uiSchema?: UiSchema;
/** Field ordering */
fieldOrder?: string[];
/** Fields to hide */
hiddenFields?: string[];
/** Fields marked as advanced (collapsed by default) */
advancedFields?: string[];
/** Whether form is disabled */
disabled?: boolean;
/** Whether form is read-only */
readonly?: boolean;
/** Whether to show submit button */
showSubmit?: boolean;
/** Custom class name */
className?: string;
/** Live validation mode */
liveValidate?: boolean;
/** Form context passed to all widgets */
formContext?: Record<string, unknown>;
}
export function ConfigForm({
schema,
formData,
onChange,
onSubmit,
onError,
uiSchema: customUiSchema,
fieldOrder,
hiddenFields,
advancedFields,
disabled = false,
readonly = false,
showSubmit = true,
className,
liveValidate = false,
formContext,
}: ConfigFormProps) {
const { t } = useTranslation(["views/settings"]);
const [showAdvanced, setShowAdvanced] = useState(false);
// Determine which fields to hide based on advanced toggle
const effectiveHiddenFields = useMemo(() => {
if (showAdvanced || !advancedFields || advancedFields.length === 0) {
return hiddenFields;
}
// Hide advanced fields when toggle is off
return [...(hiddenFields || []), ...advancedFields];
}, [hiddenFields, advancedFields, showAdvanced]);
// Transform schema and generate uiSchema
const { schema: transformedSchema, uiSchema: generatedUiSchema } = useMemo(
() =>
transformSchema(schema, {
fieldOrder,
hiddenFields: effectiveHiddenFields,
advancedFields: showAdvanced ? advancedFields : [],
}),
[schema, fieldOrder, effectiveHiddenFields, advancedFields, showAdvanced],
);
// Merge generated uiSchema with custom overrides
const finalUiSchema = useMemo(
() => ({
...generatedUiSchema,
...customUiSchema,
"ui:submitButtonOptions": showSubmit
? { norender: false }
: { norender: true },
}),
[generatedUiSchema, customUiSchema, showSubmit],
);
// Create error transformer for user-friendly error messages
const errorTransformer = useMemo(() => createErrorTransformer(), []);
const handleChange = useCallback(
(e: IChangeEvent) => {
onChange?.(e.formData);
},
[onChange],
);
const handleSubmit = useCallback(
(e: IChangeEvent) => {
onSubmit?.(e.formData);
},
[onSubmit],
);
const hasAdvancedFields = advancedFields && advancedFields.length > 0;
return (
<div className={cn("config-form", className)}>
{hasAdvancedFields && (
<div className="mb-4 flex items-center justify-end gap-2">
<Switch
id="show-advanced"
checked={showAdvanced}
onCheckedChange={setShowAdvanced}
/>
<Label
htmlFor="show-advanced"
className="cursor-pointer text-sm text-muted-foreground"
>
{t("configForm.showAdvanced", {
defaultValue: "Show Advanced Settings",
})}
</Label>
</div>
)}
<Form
schema={transformedSchema}
uiSchema={finalUiSchema}
formData={formData}
validator={validator}
onChange={handleChange}
onSubmit={handleSubmit}
onError={onError}
disabled={disabled}
readonly={readonly}
liveValidate={liveValidate}
formContext={formContext}
transformErrors={errorTransformer}
{...frigateTheme}
/>
</div>
);
}
export default ConfigForm;

View File

@ -0,0 +1,30 @@
// Audio Section Component
// Reusable for both global and camera-level audio settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the audio section
export const audioSectionConfig: SectionConfig = {
fieldOrder: [
"enabled",
"listen",
"filters",
"min_volume",
"max_not_heard",
"num_threads",
],
fieldGroups: {
detection: ["listen", "filters"],
sensitivity: ["min_volume", "max_not_heard"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["min_volume", "max_not_heard", "num_threads"],
};
export const AudioSection = createConfigSection({
sectionPath: "audio",
translationKey: "configForm.audio",
defaultConfig: audioSectionConfig,
});
export default AudioSection;

View File

@ -0,0 +1,249 @@
// Base Section Component for config form sections
// Used as a foundation for reusable section components
import { useMemo, useCallback } from "react";
import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { ConfigForm } from "../ConfigForm";
import { useConfigOverride } from "@/hooks/use-config-override";
import { useSectionSchema } from "@/hooks/use-config-schema";
import type { FrigateConfig } from "@/types/frigateConfig";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { LuRotateCcw } from "react-icons/lu";
import get from "lodash/get";
export interface SectionConfig {
/** Field ordering within the section */
fieldOrder?: string[];
/** Fields to group together */
fieldGroups?: Record<string, string[]>;
/** Fields to hide from UI */
hiddenFields?: string[];
/** Fields to show in advanced section */
advancedFields?: string[];
}
export interface BaseSectionProps {
/** Whether this is at global or camera level */
level: "global" | "camera";
/** Camera name (required if level is "camera") */
cameraName?: string;
/** Whether to show override indicator badge */
showOverrideIndicator?: boolean;
/** Custom section configuration */
sectionConfig?: SectionConfig;
/** Whether the section is disabled */
disabled?: boolean;
/** Whether the section is read-only */
readonly?: boolean;
/** Callback when settings are saved */
onSave?: () => void;
/** Whether a restart is required after changes */
requiresRestart?: boolean;
}
export interface CreateSectionOptions {
/** The config path for this section (e.g., "detect", "record") */
sectionPath: string;
/** Translation key prefix for this section */
translationKey: string;
/** Default section configuration */
defaultConfig: SectionConfig;
}
/**
* Factory function to create reusable config section components
*/
export function createConfigSection({
sectionPath,
translationKey,
defaultConfig,
}: CreateSectionOptions) {
return function ConfigSection({
level,
cameraName,
showOverrideIndicator = true,
sectionConfig = defaultConfig,
disabled = false,
readonly = false,
onSave,
requiresRestart = true,
}: BaseSectionProps) {
const { t } = useTranslation(["views/settings"]);
// Fetch config
const { data: config, mutate: refreshConfig } =
useSWR<FrigateConfig>("config");
// Get section schema using cached hook
const sectionSchema = useSectionSchema(sectionPath, level);
// Get override status
const { isOverridden, globalValue, cameraValue, resetToGlobal } =
useConfigOverride({
config,
cameraName: level === "camera" ? cameraName : undefined,
sectionPath,
});
// Get current form data
const formData = useMemo(() => {
if (!config) return {};
if (level === "camera" && cameraName) {
return get(config.cameras?.[cameraName], sectionPath) || {};
}
return get(config, sectionPath) || {};
}, [config, level, cameraName]);
// Handle form submission
const handleSubmit = useCallback(
async (data: Record<string, unknown>) => {
try {
const basePath =
level === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}`
: sectionPath;
await axios.put("config/set", {
requires_restart: requiresRestart ? 1 : 0,
config_data: {
[basePath]: data,
},
});
toast.success(
t(`${translationKey}.toast.success`, {
defaultValue: "Settings saved successfully",
}),
);
refreshConfig();
onSave?.();
} catch (error) {
// Parse Pydantic validation errors from API response
if (axios.isAxiosError(error) && error.response?.data) {
const responseData = error.response.data;
// Pydantic errors come as { detail: [...] } or { message: "..." }
if (responseData.detail && Array.isArray(responseData.detail)) {
const validationMessages = responseData.detail
.map((err: { loc?: string[]; msg?: string }) => {
const field = err.loc?.slice(1).join(".") || "unknown";
return `${field}: ${err.msg || "Invalid value"}`;
})
.join(", ");
toast.error(
t(`${translationKey}.toast.validationError`, {
defaultValue: `Validation failed: ${validationMessages}`,
}),
);
} else if (responseData.message) {
toast.error(responseData.message);
} else {
toast.error(
t(`${translationKey}.toast.error`, {
defaultValue: "Failed to save settings",
}),
);
}
} else {
toast.error(
t(`${translationKey}.toast.error`, {
defaultValue: "Failed to save settings",
}),
);
}
}
},
[level, cameraName, requiresRestart, t, refreshConfig, onSave],
);
// Handle reset to global
const handleResetToGlobal = useCallback(async () => {
if (level !== "camera" || !cameraName) return;
try {
const basePath = `cameras.${cameraName}.${sectionPath}`;
// Reset by setting to null/undefined or removing the override
await axios.put("config/set", {
requires_restart: requiresRestart ? 1 : 0,
config_data: {
[basePath]: resetToGlobal(),
},
});
toast.success(
t(`${translationKey}.toast.resetSuccess`, {
defaultValue: "Reset to global defaults",
}),
);
refreshConfig();
} catch {
toast.error(
t(`${translationKey}.toast.resetError`, {
defaultValue: "Failed to reset settings",
}),
);
}
}, [level, cameraName, requiresRestart, t, refreshConfig, resetToGlobal]);
if (!sectionSchema) {
return null;
}
const title = t(`${translationKey}.title`, {
defaultValue: sectionPath.charAt(0).toUpperCase() + sectionPath.slice(1),
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-lg">{title}</CardTitle>
{showOverrideIndicator && level === "camera" && isOverridden && (
<Badge variant="secondary" className="text-xs">
{t("common.overridden", { defaultValue: "Overridden" })}
</Badge>
)}
</div>
{level === "camera" && isOverridden && (
<Button
variant="ghost"
size="sm"
onClick={handleResetToGlobal}
className="gap-2"
>
<LuRotateCcw className="h-4 w-4" />
{t("common.resetToGlobal", { defaultValue: "Reset to Global" })}
</Button>
)}
</CardHeader>
<CardContent>
<ConfigForm
schema={sectionSchema}
formData={formData}
onSubmit={handleSubmit}
fieldOrder={sectionConfig.fieldOrder}
hiddenFields={sectionConfig.hiddenFields}
advancedFields={sectionConfig.advancedFields}
disabled={disabled}
readonly={readonly}
formContext={{
level,
cameraName,
globalValue,
cameraValue,
}}
/>
</CardContent>
</Card>
);
};
}

View File

@ -0,0 +1,37 @@
// Detect Section Component
// Reusable for both global and camera-level detect settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the detect section
export const detectSectionConfig: SectionConfig = {
fieldOrder: [
"enabled",
"fps",
"width",
"height",
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
],
fieldGroups: {
resolution: ["width", "height"],
tracking: ["min_initialized", "max_disappeared"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: [
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
],
};
export const DetectSection = createConfigSection({
sectionPath: "detect",
translationKey: "configForm.detect",
defaultConfig: detectSectionConfig,
});
export default DetectSection;

View File

@ -0,0 +1,20 @@
// Live Section Component
// Reusable for both global and camera-level live settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the live section
export const liveSectionConfig: SectionConfig = {
fieldOrder: ["stream_name", "height", "quality"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: ["quality"],
};
export const LiveSection = createConfigSection({
sectionPath: "live",
translationKey: "configForm.live",
defaultConfig: liveSectionConfig,
});
export default LiveSection;

View File

@ -0,0 +1,42 @@
// Motion Section Component
// Reusable for both global and camera-level motion settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the motion section
export const motionSectionConfig: SectionConfig = {
fieldOrder: [
"enabled",
"threshold",
"lightning_threshold",
"improve_contrast",
"contour_area",
"delta_alpha",
"frame_alpha",
"frame_height",
"mask",
"mqtt_off_delay",
],
fieldGroups: {
sensitivity: ["threshold", "lightning_threshold", "contour_area"],
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: [
"lightning_threshold",
"improve_contrast",
"contour_area",
"delta_alpha",
"frame_alpha",
"frame_height",
"mqtt_off_delay",
],
};
export const MotionSection = createConfigSection({
sectionPath: "motion",
translationKey: "configForm.motion",
defaultConfig: motionSectionConfig,
});
export default MotionSection;

View File

@ -0,0 +1,20 @@
// Notifications Section Component
// Reusable for both global and camera-level notification settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the notifications section
export const notificationsSectionConfig: SectionConfig = {
fieldOrder: ["enabled", "email"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: [],
};
export const NotificationsSection = createConfigSection({
sectionPath: "notifications",
translationKey: "configForm.notifications",
defaultConfig: notificationsSectionConfig,
});
export default NotificationsSection;

View File

@ -0,0 +1,23 @@
// Objects Section Component
// Reusable for both global and camera-level objects settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the objects section
export const objectsSectionConfig: SectionConfig = {
fieldOrder: ["track", "alert", "detect", "filters", "mask"],
fieldGroups: {
tracking: ["track", "alert", "detect"],
filtering: ["filters", "mask"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["filters", "mask"],
};
export const ObjectsSection = createConfigSection({
sectionPath: "objects",
translationKey: "configForm.objects",
defaultConfig: objectsSectionConfig,
});
export default ObjectsSection;

View File

@ -0,0 +1,32 @@
// Record Section Component
// Reusable for both global and camera-level record settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the record section
export const recordSectionConfig: SectionConfig = {
fieldOrder: [
"enabled",
"expire_interval",
"continuous",
"motion",
"alerts",
"detections",
"preview",
"export",
],
fieldGroups: {
retention: ["continuous", "motion"],
events: ["alerts", "detections"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["expire_interval", "preview", "export"],
};
export const RecordSection = createConfigSection({
sectionPath: "record",
translationKey: "configForm.record",
defaultConfig: recordSectionConfig,
});
export default RecordSection;

View File

@ -0,0 +1,20 @@
// Review Section Component
// Reusable for both global and camera-level review settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the review section
export const reviewSectionConfig: SectionConfig = {
fieldOrder: ["alerts", "detections"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: [],
};
export const ReviewSection = createConfigSection({
sectionPath: "review",
translationKey: "configForm.review",
defaultConfig: reviewSectionConfig,
});
export default ReviewSection;

View File

@ -0,0 +1,29 @@
// Snapshots Section Component
// Reusable for both global and camera-level snapshots settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the snapshots section
export const snapshotsSectionConfig: SectionConfig = {
fieldOrder: [
"enabled",
"bounding_box",
"crop",
"quality",
"timestamp",
"retain",
],
fieldGroups: {
display: ["bounding_box", "crop", "quality", "timestamp"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["quality", "retain"],
};
export const SnapshotsSection = createConfigSection({
sectionPath: "snapshots",
translationKey: "configForm.snapshots",
defaultConfig: snapshotsSectionConfig,
});
export default SnapshotsSection;

View File

@ -0,0 +1,22 @@
// Timestamp Section Component
// Reusable for both global and camera-level timestamp_style settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the timestamp_style section
export const timestampSectionConfig: SectionConfig = {
fieldOrder: ["position", "format", "color", "thickness", "effect"],
fieldGroups: {
appearance: ["color", "thickness", "effect"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["thickness", "effect"],
};
export const TimestampSection = createConfigSection({
sectionPath: "timestamp_style",
translationKey: "configForm.timestampStyle",
defaultConfig: timestampSectionConfig,
});
export default TimestampSection;

View File

@ -0,0 +1,23 @@
// Config Form Section Components
// Reusable components for both global and camera-level settings
export {
createConfigSection,
type BaseSectionProps,
type SectionConfig,
type CreateSectionOptions,
} from "./BaseSection";
export { DetectSection, detectSectionConfig } from "./DetectSection";
export { RecordSection, recordSectionConfig } from "./RecordSection";
export { SnapshotsSection, snapshotsSectionConfig } from "./SnapshotsSection";
export { MotionSection, motionSectionConfig } from "./MotionSection";
export { ObjectsSection, objectsSectionConfig } from "./ObjectsSection";
export { ReviewSection, reviewSectionConfig } from "./ReviewSection";
export { AudioSection, audioSectionConfig } from "./AudioSection";
export {
NotificationsSection,
notificationsSectionConfig,
} from "./NotificationsSection";
export { LiveSection, liveSectionConfig } from "./LiveSection";
export { TimestampSection, timestampSectionConfig } from "./TimestampSection";

View File

@ -0,0 +1,67 @@
// Utilities for handling anyOf with null patterns
import type { StrictRJSFSchema } from "@rjsf/utils";
/**
* Checks if a schema is anyOf with exactly [PrimitiveType, null]
* where the primitive has no additional constraints
*/
export function isSimpleNullableField(schema: StrictRJSFSchema): boolean {
if (
!schema.anyOf ||
!Array.isArray(schema.anyOf) ||
schema.anyOf.length !== 2
) {
return false;
}
const items = schema.anyOf;
let hasNull = false;
let simpleType: StrictRJSFSchema | null = null;
// eslint-disable-next-line no-restricted-syntax
for (const item of items) {
if (typeof item !== "object" || item === null) {
return false;
}
const itemSchema = item as StrictRJSFSchema;
if (itemSchema.type === "null") {
hasNull = true;
} else if (
itemSchema.type &&
!("$ref" in itemSchema) &&
!("additionalProperties" in itemSchema) &&
!("items" in itemSchema) &&
!("pattern" in itemSchema) &&
!("minimum" in itemSchema) &&
!("maximum" in itemSchema) &&
!("exclusiveMinimum" in itemSchema) &&
!("exclusiveMaximum" in itemSchema)
) {
simpleType = itemSchema;
}
}
return hasNull && simpleType !== null;
}
/**
* Get the non-null schema from an anyOf containing [Type, null]
*/
export function getNonNullSchema(
schema: StrictRJSFSchema,
): StrictRJSFSchema | null {
if (!schema.anyOf || !Array.isArray(schema.anyOf)) {
return null;
}
return (
(schema.anyOf.find(
(item) =>
typeof item === "object" &&
item !== null &&
(item as StrictRJSFSchema).type !== "null",
) as StrictRJSFSchema) || null
);
}

View File

@ -0,0 +1,76 @@
// Custom RJSF Theme for Frigate
// Maps RJSF templates and widgets to shadcn/ui components
import type {
WidgetProps,
FieldTemplateProps,
RegistryWidgetsType,
RegistryFieldsType,
TemplatesType,
} from "@rjsf/utils";
import { getDefaultRegistry } from "@rjsf/core";
import { SwitchWidget } from "./widgets/SwitchWidget";
import { SelectWidget } from "./widgets/SelectWidget";
import { TextWidget } from "./widgets/TextWidget";
import { PasswordWidget } from "./widgets/PasswordWidget";
import { CheckboxWidget } from "./widgets/CheckboxWidget";
import { RangeWidget } from "./widgets/RangeWidget";
import { TagsWidget } from "./widgets/TagsWidget";
import { ColorWidget } from "./widgets/ColorWidget";
import { TextareaWidget } from "./widgets/TextareaWidget";
import { FieldTemplate } from "./templates/FieldTemplate";
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
import { ArrayFieldTemplate } from "./templates/ArrayFieldTemplate";
import { BaseInputTemplate } from "./templates/BaseInputTemplate";
import { DescriptionFieldTemplate } from "./templates/DescriptionFieldTemplate";
import { TitleFieldTemplate } from "./templates/TitleFieldTemplate";
import { ErrorListTemplate } from "./templates/ErrorListTemplate";
import { SubmitButton } from "./templates/SubmitButton";
import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate";
export interface FrigateTheme {
widgets: RegistryWidgetsType;
templates: Partial<TemplatesType>;
fields: RegistryFieldsType;
}
const defaultRegistry = getDefaultRegistry();
export const frigateTheme: FrigateTheme = {
widgets: {
...defaultRegistry.widgets,
// Override default widgets with shadcn/ui styled versions
TextWidget: TextWidget,
PasswordWidget: PasswordWidget,
SelectWidget: SelectWidget,
CheckboxWidget: CheckboxWidget,
// Custom widgets
switch: SwitchWidget,
password: PasswordWidget,
select: SelectWidget,
range: RangeWidget,
tags: TagsWidget,
color: ColorWidget,
textarea: TextareaWidget,
},
templates: {
...defaultRegistry.templates,
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
ObjectFieldTemplate: ObjectFieldTemplate,
ArrayFieldTemplate: ArrayFieldTemplate,
BaseInputTemplate: BaseInputTemplate as React.ComponentType<WidgetProps>,
DescriptionFieldTemplate: DescriptionFieldTemplate,
TitleFieldTemplate: TitleFieldTemplate,
ErrorListTemplate: ErrorListTemplate,
MultiSchemaFieldTemplate: MultiSchemaFieldTemplate,
ButtonTemplates: {
...defaultRegistry.templates.ButtonTemplates,
SubmitButton: SubmitButton,
},
},
fields: {
...defaultRegistry.fields,
},
};

View File

@ -0,0 +1,5 @@
// RJSF Custom Theme
// Maps RJSF components to existing shadcn/ui components
export { frigateTheme } from "./frigateTheme";
export type { FrigateTheme } from "./frigateTheme";

View File

@ -0,0 +1,101 @@
// Array Field Template - renders array fields with add/remove controls
import type { ArrayFieldTemplateProps } from "@rjsf/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { LuPlus, LuTrash2, LuGripVertical } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
interface ArrayItem {
key: string;
index: number;
children: React.ReactNode;
hasRemove: boolean;
onDropIndexClick: (index: number) => () => void;
}
export function ArrayFieldTemplate(props: ArrayFieldTemplateProps) {
const { items, canAdd, onAddClick, disabled, readonly, schema } = props;
const { t } = useTranslation(["common"]);
// Simple items (strings, numbers) render inline
const isSimpleType =
schema.items &&
typeof schema.items === "object" &&
"type" in schema.items &&
["string", "number", "integer", "boolean"].includes(
schema.items.type as string,
);
return (
<div className="space-y-3">
{items.length === 0 && !canAdd && (
<p className="text-sm italic text-muted-foreground">
{t("no_items", { ns: "common", defaultValue: "No items" })}
</p>
)}
{(items as unknown as ArrayItem[]).map((element: ArrayItem) => (
<div
key={element.key}
className={cn("flex items-start gap-2", !isSimpleType && "flex-col")}
>
{isSimpleType ? (
<div className="flex flex-1 items-center gap-2">
<LuGripVertical className="h-4 w-4 cursor-move text-muted-foreground" />
<div className="flex-1">{element.children}</div>
{element.hasRemove && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={element.onDropIndexClick(element.index)}
disabled={disabled || readonly}
className="h-8 w-8 text-destructive hover:text-destructive"
>
<LuTrash2 className="h-4 w-4" />
</Button>
)}
</div>
) : (
<Card className="w-full">
<CardContent className="pt-4">
<div className="flex items-start gap-2">
<LuGripVertical className="mt-2 h-4 w-4 cursor-move text-muted-foreground" />
<div className="flex-1">{element.children}</div>
{element.hasRemove && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={element.onDropIndexClick(element.index)}
disabled={disabled || readonly}
className="h-8 w-8 text-destructive hover:text-destructive"
>
<LuTrash2 className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
)}
</div>
))}
{canAdd && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onAddClick}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("add", { ns: "common", defaultValue: "Add" })}
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,44 @@
// Base Input Template - default input wrapper
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
export function BaseInputTemplate(props: WidgetProps) {
const {
id,
type,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
} = props;
const inputType = type || "text";
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (inputType === "number") {
const num = parseFloat(val);
onChange(val === "" ? undefined : isNaN(num) ? undefined : num);
} else {
onChange(val === "" ? undefined : val);
}
};
return (
<Input
id={id}
type={inputType}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || ""}
onChange={handleChange}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
/>
);
}

View File

@ -0,0 +1,16 @@
// Description Field Template
import type { DescriptionFieldProps } from "@rjsf/utils";
export function DescriptionFieldTemplate(props: DescriptionFieldProps) {
const { description, id } = props;
if (!description) {
return null;
}
return (
<p id={id} className="text-sm text-muted-foreground">
{description}
</p>
);
}

View File

@ -0,0 +1,33 @@
// Error List Template - displays form-level errors
import type { ErrorListProps, RJSFValidationError } from "@rjsf/utils";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { LuCircleAlert } from "react-icons/lu";
import { useTranslation } from "react-i18next";
export function ErrorListTemplate(props: ErrorListProps) {
const { errors } = props;
const { t } = useTranslation(["common"]);
if (!errors || errors.length === 0) {
return null;
}
return (
<Alert variant="destructive" className="mb-4">
<LuCircleAlert className="h-4 w-4" />
<AlertTitle>{t("validation_errors", { ns: "common" })}</AlertTitle>
<AlertDescription>
<ul className="mt-2 list-inside list-disc space-y-1">
{errors.map((error: RJSFValidationError, index: number) => (
<li key={index} className="text-sm">
{error.property && (
<span className="font-medium">{error.property}: </span>
)}
{error.message}
</li>
))}
</ul>
</AlertDescription>
</Alert>
);
}

View File

@ -0,0 +1,81 @@
// Field Template - wraps each form field with label and description
import type { FieldTemplateProps } from "@rjsf/utils";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
export function FieldTemplate(props: FieldTemplateProps) {
const {
id,
label,
children,
errors,
help,
description,
hidden,
required,
displayLabel,
schema,
uiSchema,
} = props;
if (hidden) {
return <div className="hidden">{children}</div>;
}
// Get UI options
const uiOptions = uiSchema?.["ui:options"] || {};
const isAdvanced = uiOptions.advanced === true;
// Boolean fields (switches) render label inline
const isBoolean = schema.type === "boolean";
return (
<div
className={cn(
"space-y-2",
isAdvanced && "border-l-2 border-muted pl-4",
isBoolean && "flex items-center justify-between gap-4",
)}
>
{displayLabel && label && !isBoolean && (
<Label
htmlFor={id}
className={cn(
"text-sm font-medium",
errors && errors.props?.errors?.length > 0 && "text-destructive",
)}
>
{label}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
{isBoolean ? (
<div className="flex w-full items-center justify-between gap-4">
<div className="space-y-0.5">
{displayLabel && label && (
<Label htmlFor={id} className="text-sm font-medium">
{label}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
{children}
</div>
) : (
<>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
{children}
</>
)}
{errors}
{help}
</div>
);
}

View File

@ -0,0 +1,40 @@
// Custom MultiSchemaFieldTemplate to handle anyOf [Type, null] fields
// Renders simple nullable types as single inputs instead of dropdowns
import type {
MultiSchemaFieldTemplateProps,
StrictRJSFSchema,
FormContextType,
} from "@rjsf/utils";
import { isSimpleNullableField } from "../fields/nullableUtils";
/**
* Custom MultiSchemaFieldTemplate that:
* 1. Renders simple anyOf [Type, null] fields as single inputs
* 2. Falls back to default behavior for complex types
*/
export function MultiSchemaFieldTemplate<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T = any,
S extends StrictRJSFSchema = StrictRJSFSchema,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
F extends FormContextType = any,
>(props: MultiSchemaFieldTemplateProps<T, S, F>): JSX.Element {
const { schema, selector, optionSchemaField } = props;
// Check if this is a simple nullable field that should be handled specially
if (isSimpleNullableField(schema)) {
// For simple nullable fields, just render the field directly without the dropdown selector
// This handles the case where empty input = null
return <>{optionSchemaField}</>;
}
// For all other cases, render with both selector and field (default MultiSchemaField behavior)
return (
<>
{selector}
{optionSchemaField}
</>
);
}

View File

@ -0,0 +1,118 @@
// Object Field Template - renders nested object fields
import type { ObjectFieldTemplateProps } from "@rjsf/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const { title, description, properties } = props;
// Check if this is a root-level object
const isRoot = !title;
const [isOpen, setIsOpen] = useState(true);
// Check for advanced section grouping
const advancedProps = properties.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
);
const regularProps = properties.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
);
const [showAdvanced, setShowAdvanced] = useState(false);
// Root level renders children directly
if (isRoot) {
return (
<div className="space-y-6">
{regularProps.map((element) => (
<div key={element.name}>{element.content}</div>
))}
{advancedProps.length > 0 && (
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-2">
{showAdvanced ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
Advanced Settings ({advancedProps.length})
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{advancedProps.map((element) => (
<div key={element.name}>{element.content}</div>
))}
</CollapsibleContent>
</Collapsible>
)}
</div>
);
}
// Nested objects render as collapsible cards
return (
<Card>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer transition-colors hover:bg-muted/50">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{title}</CardTitle>
{description && (
<p className="mt-1 text-sm text-muted-foreground">
{description}
</p>
)}
</div>
{isOpen ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 pt-0">
{regularProps.map((element) => (
<div key={element.name}>{element.content}</div>
))}
{advancedProps.length > 0 && (
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2"
>
{showAdvanced ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
Advanced ({advancedProps.length})
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{advancedProps.map((element) => (
<div key={element.name}>{element.content}</div>
))}
</CollapsibleContent>
</Collapsible>
)}
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}

View File

@ -0,0 +1,21 @@
// Submit Button Template
import type { SubmitButtonProps } from "@rjsf/utils";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { LuSave } from "react-icons/lu";
export function SubmitButton(props: SubmitButtonProps) {
const { uiSchema } = props;
const { t } = useTranslation(["common"]);
const submitText =
(uiSchema?.["ui:options"]?.submitText as string) ||
t("save", { ns: "common" });
return (
<Button type="submit" className="gap-2">
<LuSave className="h-4 w-4" />
{submitText}
</Button>
);
}

View File

@ -0,0 +1,17 @@
// Title Field Template
import type { TitleFieldProps } from "@rjsf/utils";
export function TitleFieldTemplate(props: TitleFieldProps) {
const { title, id, required } = props;
if (!title) {
return null;
}
return (
<h3 id={id} className="text-lg font-semibold">
{title}
{required && <span className="ml-1 text-destructive">*</span>}
</h3>
);
}

View File

@ -0,0 +1,17 @@
// Checkbox Widget - maps to shadcn/ui Checkbox
import type { WidgetProps } from "@rjsf/utils";
import { Checkbox } from "@/components/ui/checkbox";
export function CheckboxWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, label, schema } = props;
return (
<Checkbox
id={id}
checked={typeof value === "undefined" ? false : value}
disabled={disabled || readonly}
onCheckedChange={(checked) => onChange(checked)}
aria-label={label || schema.title || "Checkbox"}
/>
);
}

View File

@ -0,0 +1,53 @@
// Color Widget - For RGB color objects
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useMemo, useCallback } from "react";
interface RGBColor {
red: number;
green: number;
blue: number;
}
export function ColorWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange } = props;
// Convert object to hex for color picker
const hexValue = useMemo(() => {
if (!value || typeof value !== "object") {
return "#ffffff";
}
const { red = 255, green = 255, blue = 255 } = value as RGBColor;
return `#${red.toString(16).padStart(2, "0")}${green.toString(16).padStart(2, "0")}${blue.toString(16).padStart(2, "0")}`;
}, [value]);
const handleColorChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const hex = e.target.value;
const red = parseInt(hex.slice(1, 3), 16);
const green = parseInt(hex.slice(3, 5), 16);
const blue = parseInt(hex.slice(5, 7), 16);
onChange({ red, green, blue });
},
[onChange],
);
return (
<div className="flex items-center gap-3">
<Input
id={id}
type="color"
value={hexValue}
disabled={disabled || readonly}
onChange={handleColorChange}
className="h-10 w-14 cursor-pointer p-1"
/>
<div className="flex gap-2 text-sm text-muted-foreground">
<Label>R: {(value as RGBColor)?.red ?? 255}</Label>
<Label>G: {(value as RGBColor)?.green ?? 255}</Label>
<Label>B: {(value as RGBColor)?.blue ?? 255}</Label>
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
// Number Widget - Input with number type
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
export function NumberWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
schema,
options,
} = props;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (val === "") {
onChange(undefined);
} else {
const num =
schema.type === "integer" ? parseInt(val, 10) : parseFloat(val);
onChange(isNaN(num) ? undefined : num);
}
};
return (
<Input
id={id}
type="number"
value={value ?? ""}
disabled={disabled || readonly}
min={schema.minimum}
max={schema.maximum}
step={(options.step as number) || (schema.type === "integer" ? 1 : 0.1)}
onChange={handleChange}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
/>
);
}

View File

@ -0,0 +1,55 @@
// Password Widget - Input with password type
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
export function PasswordWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
} = props;
const [showPassword, setShowPassword] = useState(false);
return (
<div className="relative">
<Input
id={id}
type={showPassword ? "text" : "password"}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
disabled={disabled}
>
{showPassword ? (
<LuEyeOff className="h-4 w-4" />
) : (
<LuEye className="h-4 w-4" />
)}
</Button>
</div>
);
}

View File

@ -0,0 +1,31 @@
// Range Widget - maps to shadcn/ui Slider
import type { WidgetProps } from "@rjsf/utils";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
export function RangeWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, schema, options } = props;
const min = schema.minimum ?? 0;
const max = schema.maximum ?? 100;
const step =
(options.step as number) || (schema.type === "integer" ? 1 : 0.1);
return (
<div className="flex items-center gap-4">
<Slider
id={id}
value={[value ?? min]}
min={min}
max={max}
step={step}
disabled={disabled || readonly}
onValueChange={(vals) => onChange(vals[0])}
className={cn("flex-1", disabled && "opacity-50")}
/>
<span className="w-12 text-right text-sm text-muted-foreground">
{value ?? min}
</span>
</div>
);
}

View File

@ -0,0 +1,49 @@
// Select Widget - maps to shadcn/ui Select
import type { WidgetProps } from "@rjsf/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function SelectWidget(props: WidgetProps) {
const {
id,
options,
value,
disabled,
readonly,
onChange,
placeholder,
schema,
} = props;
const { enumOptions = [] } = options;
return (
<Select
value={value?.toString() ?? ""}
onValueChange={(val) => {
// Convert back to original type if needed
const enumVal = enumOptions.find(
(opt: { value: unknown }) => opt.value?.toString() === val,
);
onChange(enumVal ? enumVal.value : val);
}}
disabled={disabled || readonly}
>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder={placeholder || schema.title || "Select..."} />
</SelectTrigger>
<SelectContent>
{enumOptions.map((option: { value: unknown; label: string }) => (
<SelectItem key={String(option.value)} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@ -0,0 +1,17 @@
// Switch Widget - maps to shadcn/ui Switch
import type { WidgetProps } from "@rjsf/utils";
import { Switch } from "@/components/ui/switch";
export function SwitchWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, label, schema } = props;
return (
<Switch
id={id}
checked={typeof value === "undefined" ? false : value}
disabled={disabled || readonly}
onCheckedChange={(checked) => onChange(checked)}
aria-label={label || schema.title || "Toggle"}
/>
);
}

View File

@ -0,0 +1,74 @@
// Tags Widget - For array of strings input
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useState, useCallback, useMemo } from "react";
import { LuX } from "react-icons/lu";
export function TagsWidget(props: WidgetProps) {
const { id, value = [], disabled, readonly, onChange, schema } = props;
const [inputValue, setInputValue] = useState("");
const tags = useMemo(() => (Array.isArray(value) ? value : []), [value]);
const addTag = useCallback(() => {
const trimmed = inputValue.trim();
if (trimmed && !tags.includes(trimmed)) {
onChange([...tags, trimmed]);
setInputValue("");
}
}, [inputValue, tags, onChange]);
const removeTag = useCallback(
(tagToRemove: string) => {
onChange(tags.filter((tag: string) => tag !== tagToRemove));
},
[tags, onChange],
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
} else if (e.key === "Backspace" && inputValue === "" && tags.length > 0) {
removeTag(tags[tags.length - 1]);
}
};
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1">
{tags.map((tag: string, index: number) => (
<Badge key={`${tag}-${index}`} variant="secondary" className="gap-1">
{tag}
{!disabled && !readonly && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto p-0 hover:bg-transparent"
onClick={() => removeTag(tag)}
>
<LuX className="h-3 w-3" />
</Button>
)}
</Badge>
))}
</div>
{!readonly && (
<Input
id={id}
type="text"
value={inputValue}
disabled={disabled}
placeholder={`Add ${schema.title?.toLowerCase() || "item"}...`}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={addTag}
/>
)}
</div>
);
}

View File

@ -0,0 +1,34 @@
// Text Widget - maps to shadcn/ui Input
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
export function TextWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
return (
<Input
id={id}
type="text"
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
/>
);
}

View File

@ -0,0 +1,34 @@
// Textarea Widget - maps to shadcn/ui Textarea
import type { WidgetProps } from "@rjsf/utils";
import { Textarea } from "@/components/ui/textarea";
export function TextareaWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
return (
<Textarea
id={id}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
rows={(options.rows as number) || 3}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
/>
);
}

View File

@ -0,0 +1,182 @@
// Hook to detect when camera config overrides global defaults
import { useMemo } from "react";
import isEqual from "lodash/isEqual";
import get from "lodash/get";
import type { FrigateConfig } from "@/types/frigateConfig";
export interface OverrideStatus {
/** Whether the field is overridden from global */
isOverridden: boolean;
/** The global default value */
globalValue: unknown;
/** The camera-specific value */
cameraValue: unknown;
}
export interface UseConfigOverrideOptions {
/** Full Frigate config */
config: FrigateConfig | undefined;
/** Camera name for per-camera settings */
cameraName?: string;
/** Config section path (e.g., "detect", "record.events") */
sectionPath: string;
}
/**
* Hook to detect config overrides between global and camera level
*
* @example
* ```tsx
* const { isOverridden, getFieldOverride } = useConfigOverride({
* config,
* cameraName: "front_door",
* sectionPath: "detect"
* });
*
* // Check if entire section is overridden
* if (isOverridden) {
* // Show override indicator
* }
*
* // Check specific field
* const fpsOverride = getFieldOverride("fps");
* ```
*/
export function useConfigOverride({
config,
cameraName,
sectionPath,
}: UseConfigOverrideOptions) {
return useMemo(() => {
if (!config) {
return {
isOverridden: false,
globalValue: undefined,
cameraValue: undefined,
getFieldOverride: () => ({
isOverridden: false,
globalValue: undefined,
cameraValue: undefined,
}),
resetToGlobal: () => undefined,
};
}
// Get global value for the section
const globalValue = get(config, sectionPath);
// If no camera specified, return global value info
if (!cameraName) {
return {
isOverridden: false,
globalValue,
cameraValue: globalValue,
getFieldOverride: (fieldPath: string): OverrideStatus => ({
isOverridden: false,
globalValue: get(globalValue, fieldPath),
cameraValue: get(globalValue, fieldPath),
}),
resetToGlobal: () => globalValue,
};
}
// Get camera-specific value
const cameraConfig = config.cameras?.[cameraName];
if (!cameraConfig) {
return {
isOverridden: false,
globalValue,
cameraValue: undefined,
getFieldOverride: () => ({
isOverridden: false,
globalValue: undefined,
cameraValue: undefined,
}),
resetToGlobal: () => globalValue,
};
}
const cameraValue = get(cameraConfig, sectionPath);
// Check if the entire section is overridden
const isOverridden = !isEqual(globalValue, cameraValue);
/**
* Get override status for a specific field within the section
*/
const getFieldOverride = (fieldPath: string): OverrideStatus => {
const globalFieldValue = get(globalValue, fieldPath);
const cameraFieldValue = get(cameraValue, fieldPath);
return {
isOverridden: !isEqual(globalFieldValue, cameraFieldValue),
globalValue: globalFieldValue,
cameraValue: cameraFieldValue,
};
};
/**
* Returns the global value to reset camera override
*/
const resetToGlobal = (fieldPath?: string) => {
if (fieldPath) {
return get(globalValue, fieldPath);
}
return globalValue;
};
return {
isOverridden,
globalValue,
cameraValue,
getFieldOverride,
resetToGlobal,
};
}, [config, cameraName, sectionPath]);
}
/**
* Hook to get all overridden fields for a camera
*/
export function useAllCameraOverrides(
config: FrigateConfig | undefined,
cameraName: string | undefined,
) {
return useMemo(() => {
if (!config || !cameraName) {
return [];
}
const cameraConfig = config.cameras?.[cameraName];
if (!cameraConfig) {
return [];
}
const overriddenSections: string[] = [];
// Check each section that can be overridden
const sectionsToCheck = [
"detect",
"record",
"snapshots",
"motion",
"objects",
"review",
"audio",
"notifications",
"live",
"timestamp_style",
];
for (const section of sectionsToCheck) {
const globalValue = get(config, section);
const cameraValue = get(cameraConfig, section);
if (!isEqual(globalValue, cameraValue)) {
overriddenSections.push(section);
}
}
return overriddenSections;
}, [config, cameraName]);
}

View File

@ -0,0 +1,128 @@
// Hook for efficiently working with config schemas
// Caches resolved section schemas to avoid repeated expensive resolution
import { useMemo } from "react";
import useSWR from "swr";
import type { RJSFSchema } from "@rjsf/utils";
import { resolveAndCleanSchema } from "@/lib/config-schema";
// Cache for resolved section schemas - keyed by schema reference + section key
const sectionSchemaCache = new WeakMap<RJSFSchema, Map<string, RJSFSchema>>();
/**
* Extracts and resolves a section schema from the full config schema
* Uses caching to avoid repeated expensive resolution
*/
function extractSectionSchema(
schema: RJSFSchema,
sectionPath: string,
level: "global" | "camera",
): RJSFSchema | null {
// Create cache key
const cacheKey = `${level}:${sectionPath}`;
// Check cache first (using WeakMap with schema as key for proper garbage collection)
let schemaCache = sectionSchemaCache.get(schema);
if (!schemaCache) {
schemaCache = new Map<string, RJSFSchema>();
sectionSchemaCache.set(schema, schemaCache);
}
if (schemaCache.has(cacheKey)) {
return schemaCache.get(cacheKey)!;
}
const schemaObj = schema as Record<string, unknown>;
const defs = (schemaObj.$defs || schemaObj.definitions || {}) as Record<
string,
unknown
>;
let sectionDef: Record<string, unknown> | null = null;
// For camera level, get section from CameraConfig in $defs
if (level === "camera") {
const cameraConfigDef = defs.CameraConfig as
| Record<string, unknown>
| undefined;
if (cameraConfigDef?.properties) {
const props = cameraConfigDef.properties as Record<string, unknown>;
const sectionProp = props[sectionPath];
if (sectionProp && typeof sectionProp === "object") {
const refProp = sectionProp as Record<string, unknown>;
if (refProp.$ref && typeof refProp.$ref === "string") {
const refPath = (refProp.$ref as string)
.replace(/^#\/\$defs\//, "")
.replace(/^#\/definitions\//, "");
sectionDef = defs[refPath] as Record<string, unknown>;
} else {
sectionDef = sectionProp as Record<string, unknown>;
}
}
}
} else {
// For global level, get from root properties
if (schemaObj.properties) {
const props = schemaObj.properties as Record<string, unknown>;
const sectionProp = props[sectionPath];
if (sectionProp && typeof sectionProp === "object") {
const refProp = sectionProp as Record<string, unknown>;
if (refProp.$ref && typeof refProp.$ref === "string") {
const refPath = (refProp.$ref as string)
.replace(/^#\/\$defs\//, "")
.replace(/^#\/definitions\//, "");
sectionDef = defs[refPath] as Record<string, unknown>;
} else {
sectionDef = sectionProp as Record<string, unknown>;
}
}
}
}
if (!sectionDef) return null;
// Include $defs for nested references and resolve them
const schemaWithDefs = {
...sectionDef,
$defs: defs,
} as RJSFSchema;
// Resolve all references and strip $defs from result
const resolved = resolveAndCleanSchema(schemaWithDefs);
// Cache the result
schemaCache.set(cacheKey, resolved);
return resolved;
}
/**
* Note: Cache is automatically cleared when schema changes since we use WeakMap
* with the schema object as key. No manual clearing needed.
*/
/**
* Hook to get a resolved section schema
* Efficiently caches resolved schemas to avoid repeated expensive operations
*/
export function useSectionSchema(
sectionPath: string,
level: "global" | "camera",
): RJSFSchema | null {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return useMemo(() => {
if (!schema) return null;
return extractSectionSchema(schema, sectionPath, level);
}, [schema, sectionPath, level]);
}
/**
* Hook to get the raw config schema
*/
export function useConfigSchema(): RJSFSchema | undefined {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return schema;
}

View File

@ -0,0 +1,99 @@
// Custom error messages for RJSF validation
// Maps JSON Schema validation keywords to user-friendly messages
import type { ErrorTransformer } from "@rjsf/utils";
export interface ErrorMessageMap {
[keyword: string]: string | ((params: Record<string, unknown>) => string);
}
// Default error messages for common validation keywords
export const defaultErrorMessages: ErrorMessageMap = {
required: "This field is required",
type: (params) => {
const expectedType = params.type as string;
return `Expected ${expectedType} value`;
},
minimum: (params) => `Must be at least ${params.limit}`,
maximum: (params) => `Must be at most ${params.limit}`,
minLength: (params) => `Must be at least ${params.limit} characters`,
maxLength: (params) => `Must be at most ${params.limit} characters`,
pattern: "Invalid format",
format: (params) => {
const format = params.format as string;
const formatLabels: Record<string, string> = {
email: "Invalid email address",
uri: "Invalid URL",
"date-time": "Invalid date/time format",
ipv4: "Invalid IP address",
ipv6: "Invalid IPv6 address",
};
return formatLabels[format] || `Invalid ${format} format`;
},
enum: "Must be one of the allowed values",
const: "Value does not match expected constant",
uniqueItems: "All items must be unique",
minItems: (params) => `Must have at least ${params.limit} items`,
maxItems: (params) => `Must have at most ${params.limit} items`,
additionalProperties: "Unknown property is not allowed",
oneOf: "Must match exactly one of the allowed schemas",
anyOf: "Must match at least one of the allowed schemas",
};
/**
* Creates an error transformer function for RJSF
* Transforms technical JSON Schema errors into user-friendly messages
*/
export function createErrorTransformer(
customMessages: ErrorMessageMap = {},
): ErrorTransformer {
const messages = { ...defaultErrorMessages, ...customMessages };
return (errors) => {
return errors.map((error) => {
const keyword = error.name || "";
const messageTemplate = messages[keyword];
if (!messageTemplate) {
return error;
}
let message: string;
if (typeof messageTemplate === "function") {
message = messageTemplate(error.params || {});
} else {
message = messageTemplate;
}
return {
...error,
message,
};
});
};
}
/**
* Extracts field path from a Pydantic validation error location
*/
export function extractFieldPath(loc: (string | number)[]): string {
// Skip the first element if it's 'body' (FastAPI adds this)
const startIndex = loc[0] === "body" ? 1 : 0;
return loc.slice(startIndex).join(".");
}
/**
* Transforms Pydantic validation errors into RJSF-compatible errors
*/
export function transformPydanticErrors(
pydanticErrors: Array<{
loc: (string | number)[];
msg: string;
type: string;
}>,
): Array<{ property: string; message: string }> {
return pydanticErrors.map((error) => ({
property: extractFieldPath(error.loc),
message: error.msg,
}));
}

View File

@ -0,0 +1,19 @@
// Config Schema Utilities
// This module provides utilities for working with Frigate's JSON Schema
export {
transformSchema,
resolveSchemaRefs,
resolveAndCleanSchema,
extractSchemaSection,
applySchemaDefaults,
} from "./transformer";
export type { TransformedSchema, UiSchemaOptions } from "./transformer";
export {
createErrorTransformer,
transformPydanticErrors,
extractFieldPath,
defaultErrorMessages,
} from "./errorMessages";
export type { ErrorMessageMap } from "./errorMessages";

View File

@ -0,0 +1,447 @@
// Schema Transformer
// Converts Pydantic-generated JSON Schema to RJSF-compatible format with uiSchema
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
export interface TransformedSchema {
schema: RJSFSchema;
uiSchema: UiSchema;
}
export interface UiSchemaOptions {
/** Field ordering for the schema */
fieldOrder?: string[];
/** Fields to hide from the form */
hiddenFields?: string[];
/** Fields to mark as advanced (collapsed by default) */
advancedFields?: string[];
/** Custom widget mappings */
widgetMappings?: Record<string, string>;
/** Whether to include descriptions */
includeDescriptions?: boolean;
}
// Type guard for schema objects
function isSchemaObject(
schema: unknown,
): schema is RJSFSchema & Record<string, unknown> {
return typeof schema === "object" && schema !== null;
}
/**
* Resolves $ref references in a JSON Schema
* This converts Pydantic's $defs-based schema to inline schemas
*/
export function resolveSchemaRefs(
schema: RJSFSchema,
rootSchema?: RJSFSchema,
): RJSFSchema {
const root = rootSchema || schema;
const defs =
(root as Record<string, unknown>).$defs ||
(root as Record<string, unknown>).definitions ||
{};
if (!schema || typeof schema !== "object") {
return schema;
}
const schemaObj = schema as Record<string, unknown>;
// Handle $ref
if (schemaObj.$ref && typeof schemaObj.$ref === "string") {
const refPath = schemaObj.$ref
.replace(/^#\/\$defs\//, "")
.replace(/^#\/definitions\//, "");
const resolved = (defs as Record<string, unknown>)[refPath];
if (isSchemaObject(resolved)) {
// Merge any additional properties from the original schema
const { $ref: _ref, ...rest } = schemaObj;
return resolveSchemaRefs({ ...resolved, ...rest } as RJSFSchema, root);
}
return schema;
}
// Handle allOf (Pydantic uses this for inheritance)
if (Array.isArray(schemaObj.allOf)) {
const merged: Record<string, unknown> = {};
for (const subSchema of schemaObj.allOf) {
if (isSchemaObject(subSchema)) {
const resolved = resolveSchemaRefs(subSchema as RJSFSchema, root);
Object.assign(merged, resolved);
if (
isSchemaObject(resolved) &&
(resolved as Record<string, unknown>).properties
) {
merged.properties = {
...(merged.properties as object),
...((resolved as Record<string, unknown>).properties as object),
};
}
if (
isSchemaObject(resolved) &&
Array.isArray((resolved as Record<string, unknown>).required)
) {
merged.required = [
...((merged.required as string[]) || []),
...((resolved as Record<string, unknown>).required as string[]),
];
}
}
}
// Include any extra properties from the parent schema
const { allOf: _allOf, ...rest } = schemaObj;
return { ...merged, ...rest } as RJSFSchema;
}
// Handle anyOf/oneOf
if (Array.isArray(schemaObj.anyOf)) {
return {
...schemaObj,
anyOf: schemaObj.anyOf
.filter(isSchemaObject)
.map((s) => resolveSchemaRefs(s as RJSFSchema, root)),
} as RJSFSchema;
}
if (Array.isArray(schemaObj.oneOf)) {
return {
...schemaObj,
oneOf: schemaObj.oneOf
.filter(isSchemaObject)
.map((s) => resolveSchemaRefs(s as RJSFSchema, root)),
} as RJSFSchema;
}
// Handle properties
if (isSchemaObject(schemaObj.properties)) {
const resolvedProps: Record<string, RJSFSchema> = {};
for (const [key, prop] of Object.entries(
schemaObj.properties as Record<string, unknown>,
)) {
if (isSchemaObject(prop)) {
resolvedProps[key] = resolveSchemaRefs(prop as RJSFSchema, root);
}
}
return { ...schemaObj, properties: resolvedProps } as RJSFSchema;
}
// Handle items (for arrays)
if (schemaObj.items) {
if (Array.isArray(schemaObj.items)) {
return {
...schemaObj,
items: schemaObj.items
.filter(isSchemaObject)
.map((item) => resolveSchemaRefs(item as RJSFSchema, root)),
} as RJSFSchema;
} else if (isSchemaObject(schemaObj.items)) {
return {
...schemaObj,
items: resolveSchemaRefs(schemaObj.items as RJSFSchema, root),
} as RJSFSchema;
}
}
// Handle additionalProperties (for dicts)
if (
schemaObj.additionalProperties &&
isSchemaObject(schemaObj.additionalProperties)
) {
return {
...schemaObj,
additionalProperties: resolveSchemaRefs(
schemaObj.additionalProperties as RJSFSchema,
root,
),
} as RJSFSchema;
}
return schema;
}
/**
* Wrapper that resolves refs and strips $defs from result
* Use this as the main entry point for resolving schemas
*/
export function resolveAndCleanSchema(schema: RJSFSchema): RJSFSchema {
const resolved = resolveSchemaRefs(schema);
// Remove $defs from result - they're no longer needed after resolution
const {
$defs: _defs,
definitions: _definitions,
...cleanSchema
} = resolved as Record<string, unknown>;
return cleanSchema as RJSFSchema;
}
/**
* Determines the appropriate widget for a schema field
*/
function getWidgetForField(
fieldName: string,
fieldSchema: RJSFSchema,
customMappings?: Record<string, string>,
): string | undefined {
// Check custom mappings first
if (customMappings?.[fieldName]) {
return customMappings[fieldName];
}
const schemaObj = fieldSchema as Record<string, unknown>;
// Password fields
if (
fieldName.toLowerCase().includes("password") ||
fieldName.toLowerCase().includes("secret")
) {
return "password";
}
// Color fields
if (
fieldName.toLowerCase().includes("color") &&
schemaObj.type === "object"
) {
return "color";
}
// Enum fields get select widget
if (schemaObj.enum) {
return "select";
}
// Boolean fields get switch widget
if (schemaObj.type === "boolean") {
return "switch";
}
// Number with range gets slider
if (
(schemaObj.type === "number" || schemaObj.type === "integer") &&
schemaObj.minimum !== undefined &&
schemaObj.maximum !== undefined
) {
return "range";
}
// Array of strings gets tags widget
if (
schemaObj.type === "array" &&
isSchemaObject(schemaObj.items) &&
(schemaObj.items as Record<string, unknown>).type === "string"
) {
return "tags";
}
return undefined;
}
/**
* Generates a uiSchema for a given JSON Schema
*/
function generateUiSchema(
schema: RJSFSchema,
options: UiSchemaOptions = {},
): UiSchema {
const uiSchema: UiSchema = {};
const {
fieldOrder,
hiddenFields = [],
advancedFields = [],
widgetMappings = {},
includeDescriptions = true,
} = options;
const schemaObj = schema as Record<string, unknown>;
// Set field ordering
if (fieldOrder && fieldOrder.length > 0) {
uiSchema["ui:order"] = [...fieldOrder, "*"];
}
if (!isSchemaObject(schemaObj.properties)) {
return uiSchema;
}
for (const [fieldName, fieldSchema] of Object.entries(
schemaObj.properties as Record<string, unknown>,
)) {
if (!isSchemaObject(fieldSchema)) continue;
const fSchema = fieldSchema as Record<string, unknown>;
const fieldUiSchema: UiSchema = {};
// Hidden fields
if (hiddenFields.includes(fieldName)) {
fieldUiSchema["ui:widget"] = "hidden";
uiSchema[fieldName] = fieldUiSchema;
continue;
}
// Widget selection
const widget = getWidgetForField(
fieldName,
fieldSchema as RJSFSchema,
widgetMappings,
);
if (widget) {
fieldUiSchema["ui:widget"] = widget;
}
// Description
if (!includeDescriptions && fSchema.description) {
fieldUiSchema["ui:description"] = "";
}
// Advanced fields - mark for collapsible
if (advancedFields.includes(fieldName)) {
fieldUiSchema["ui:options"] = {
...((fieldUiSchema["ui:options"] as object) || {}),
advanced: true,
};
}
// Handle nested objects recursively
if (fSchema.type === "object" && isSchemaObject(fSchema.properties)) {
const nestedOptions: UiSchemaOptions = {
hiddenFields: hiddenFields
.filter((f) => f.startsWith(`${fieldName}.`))
.map((f) => f.replace(`${fieldName}.`, "")),
advancedFields: advancedFields
.filter((f) => f.startsWith(`${fieldName}.`))
.map((f) => f.replace(`${fieldName}.`, "")),
widgetMappings: Object.fromEntries(
Object.entries(widgetMappings)
.filter(([k]) => k.startsWith(`${fieldName}.`))
.map(([k, v]) => [k.replace(`${fieldName}.`, ""), v]),
),
includeDescriptions,
};
Object.assign(
fieldUiSchema,
generateUiSchema(fieldSchema as RJSFSchema, nestedOptions),
);
}
if (Object.keys(fieldUiSchema).length > 0) {
uiSchema[fieldName] = fieldUiSchema;
}
}
return uiSchema;
}
/**
* Transforms a Pydantic JSON Schema to RJSF format
* Resolves references and generates appropriate uiSchema
*/
export function transformSchema(
rawSchema: RJSFSchema,
options: UiSchemaOptions = {},
): TransformedSchema {
// Resolve all $ref references and clean the result
const cleanSchema = resolveAndCleanSchema(rawSchema);
// Generate uiSchema
const uiSchema = generateUiSchema(cleanSchema, options);
return {
schema: cleanSchema,
uiSchema,
};
}
/**
* Extracts a subsection of the schema by path
* Useful for rendering individual config sections
*/
export function extractSchemaSection(
schema: RJSFSchema,
path: string,
): RJSFSchema | null {
const schemaObj = schema as Record<string, unknown>;
const defs = (schemaObj.$defs || schemaObj.definitions || {}) as Record<
string,
unknown
>;
const parts = path.split(".");
let current = schema as Record<string, unknown>;
for (const part of parts) {
if (!isSchemaObject(current.properties)) {
return null;
}
let propSchema = (current.properties as Record<string, unknown>)[
part
] as Record<string, unknown>;
if (!propSchema) {
return null;
}
// Resolve $ref if present
if (propSchema.$ref && typeof propSchema.$ref === "string") {
const refPath = (propSchema.$ref as string)
.replace(/^#\/\$defs\//, "")
.replace(/^#\/definitions\//, "");
const resolved = defs[refPath] as Record<string, unknown>;
if (resolved) {
// Merge any additional properties from original
const { $ref: _ref, ...rest } = propSchema;
propSchema = { ...resolved, ...rest };
} else {
return null;
}
}
current = propSchema;
}
// Return section schema with $defs included for nested ref resolution
const sectionWithDefs = {
...current,
$defs: defs,
} as RJSFSchema;
// Resolve all nested refs and clean the result
return resolveAndCleanSchema(sectionWithDefs);
}
/**
* Merges default values from schema into form data
*/
export function applySchemaDefaults(
schema: RJSFSchema,
formData: Record<string, unknown> = {},
): Record<string, unknown> {
const result = { ...formData };
const schemaObj = schema as Record<string, unknown>;
if (!isSchemaObject(schemaObj.properties)) {
return result;
}
for (const [key, prop] of Object.entries(
schemaObj.properties as Record<string, unknown>,
)) {
if (!isSchemaObject(prop)) continue;
const propSchema = prop as Record<string, unknown>;
if (result[key] === undefined && propSchema.default !== undefined) {
result[key] = propSchema.default;
} else if (
propSchema.type === "object" &&
isSchemaObject(propSchema.properties) &&
result[key] !== undefined
) {
result[key] = applySchemaDefaults(
prop as RJSFSchema,
result[key] as Record<string, unknown>,
);
}
}
return result;
}

View File

@ -37,6 +37,8 @@ import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView";
import UiSettingsView from "@/views/settings/UiSettingsView";
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
import MaintenanceSettingsView from "@/views/settings/MaintenanceSettingsView";
import GlobalConfigView from "@/views/settings/GlobalConfigView";
import CameraConfigView from "@/views/settings/CameraConfigView";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useInitialCameraState } from "@/api/ws";
@ -71,6 +73,8 @@ import {
const allSettingsViews = [
"ui",
"globalConfig",
"cameraConfig",
"enrichments",
"cameraManagement",
"cameraReview",
@ -89,11 +93,15 @@ type SettingsType = (typeof allSettingsViews)[number];
const settingsGroups = [
{
label: "general",
items: [{ key: "ui", component: UiSettingsView }],
items: [
{ key: "ui", component: UiSettingsView },
{ key: "globalConfig", component: GlobalConfigView },
],
},
{
label: "cameras",
items: [
{ key: "cameraConfig", component: CameraConfigView },
{ key: "cameraManagement", component: CameraManagementView },
{ key: "cameraReview", component: CameraReviewSettingsView },
{ key: "masksAndZones", component: MasksAndZonesView },
@ -130,6 +138,7 @@ const settingsGroups = [
const CAMERA_SELECT_BUTTON_PAGES = [
"debug",
"cameraConfig",
"cameraReview",
"masksAndZones",
"motionTuner",

View File

@ -0,0 +1,265 @@
// Camera Configuration View
// Per-camera configuration with tab navigation and override indicators
import { useMemo, useCallback, useState } from "react";
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import {
DetectSection,
RecordSection,
SnapshotsSection,
MotionSection,
ObjectsSection,
ReviewSection,
AudioSection,
NotificationsSection,
LiveSection,
TimestampSection,
} from "@/components/config-form/sections";
import { useAllCameraOverrides } from "@/hooks/use-config-override";
import type { FrigateConfig } from "@/types/frigateConfig";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { cn } from "@/lib/utils";
interface CameraConfigViewProps {
/** Currently selected camera (from parent) */
selectedCamera?: string;
/** Callback when unsaved changes state changes */
setUnsavedChanges?: React.Dispatch<React.SetStateAction<boolean>>;
}
export default function CameraConfigView({
selectedCamera: externalSelectedCamera,
setUnsavedChanges,
}: CameraConfigViewProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: refreshConfig } =
useSWR<FrigateConfig>("config");
// Get list of cameras
const cameras = useMemo(() => {
if (!config?.cameras) return [];
return Object.keys(config.cameras).sort();
}, [config]);
// Selected camera state (use external if provided, else internal)
const [internalSelectedCamera, setInternalSelectedCamera] = useState<string>(
cameras[0] || "",
);
const selectedCamera = externalSelectedCamera || internalSelectedCamera;
// Get overridden sections for current camera
const overriddenSections = useAllCameraOverrides(config, selectedCamera);
const handleSave = useCallback(() => {
refreshConfig();
setUnsavedChanges?.(false);
}, [refreshConfig, setUnsavedChanges]);
const handleCameraChange = useCallback((camera: string) => {
setInternalSelectedCamera(camera);
}, []);
if (!config) {
return (
<div className="flex h-full items-center justify-center">
<ActivityIndicator />
</div>
);
}
if (cameras.length === 0) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
{t("configForm.camera.noCameras", {
defaultValue: "No cameras configured",
})}
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">
{t("configForm.camera.title", {
defaultValue: "Camera Configuration",
})}
</h2>
<p className="text-muted-foreground">
{t("configForm.camera.description", {
defaultValue:
"Configure settings for individual cameras. Overridden settings are highlighted.",
})}
</p>
</div>
{/* Camera Tabs - Only show if not externally controlled */}
{!externalSelectedCamera && (
<Tabs
value={selectedCamera}
onValueChange={handleCameraChange}
className="w-full"
>
<ScrollArea className="w-full">
<TabsList className="inline-flex w-max">
{cameras.map((camera) => {
const cameraOverrides = overriddenSections.filter((s) =>
s.startsWith(camera),
);
const hasOverrides = cameraOverrides.length > 0;
const cameraConfig = config.cameras[camera];
const displayName = cameraConfig?.name || camera;
return (
<TabsTrigger
key={camera}
value={camera}
className="relative gap-2"
>
{displayName}
{hasOverrides && (
<Badge variant="secondary" className="ml-1 h-5 px-1.5">
{cameraOverrides.length}
</Badge>
)}
</TabsTrigger>
);
})}
</TabsList>
</ScrollArea>
{cameras.map((camera) => (
<TabsContent key={camera} value={camera} className="mt-4">
<CameraConfigContent
cameraName={camera}
config={config}
overriddenSections={overriddenSections}
onSave={handleSave}
/>
</TabsContent>
))}
</Tabs>
)}
{/* Direct content when externally controlled */}
{externalSelectedCamera && (
<CameraConfigContent
cameraName={externalSelectedCamera}
config={config}
overriddenSections={overriddenSections}
onSave={handleSave}
/>
)}
</div>
);
}
interface CameraConfigContentProps {
cameraName: string;
config: FrigateConfig;
overriddenSections: string[];
onSave: () => void;
}
function CameraConfigContent({
cameraName,
config,
overriddenSections,
onSave,
}: CameraConfigContentProps) {
const { t } = useTranslation(["views/settings"]);
const [activeSection, setActiveSection] = useState("detect");
const cameraConfig = config.cameras?.[cameraName];
if (!cameraConfig) {
return (
<div className="text-muted-foreground">
{t("configForm.camera.notFound", { defaultValue: "Camera not found" })}
</div>
);
}
const sections = [
{ key: "detect", label: "Detect", component: DetectSection },
{ key: "record", label: "Record", component: RecordSection },
{ key: "snapshots", label: "Snapshots", component: SnapshotsSection },
{ key: "motion", label: "Motion", component: MotionSection },
{ key: "objects", label: "Objects", component: ObjectsSection },
{ key: "review", label: "Review", component: ReviewSection },
{ key: "audio", label: "Audio", component: AudioSection },
{
key: "notifications",
label: "Notifications",
component: NotificationsSection,
},
{ key: "live", label: "Live", component: LiveSection },
{ key: "timestamp_style", label: "Timestamp", component: TimestampSection },
];
return (
<div className="flex gap-6">
{/* Section Navigation */}
<nav className="w-48 shrink-0">
<ul className="space-y-1">
{sections.map((section) => {
const isOverridden = overriddenSections.includes(section.key);
return (
<li key={section.key}>
<button
onClick={() => setActiveSection(section.key)}
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors",
activeSection === section.key
? "bg-accent text-accent-foreground"
: "hover:bg-muted",
)}
>
<span>
{t(`configForm.${section.key}.title`, {
defaultValue: section.label,
})}
</span>
{isOverridden && (
<Badge variant="secondary" className="h-5 px-1.5 text-xs">
{t("common.modified", { defaultValue: "Modified" })}
</Badge>
)}
</button>
</li>
);
})}
</ul>
</nav>
{/* Section Content */}
<ScrollArea className="h-[calc(100vh-300px)] flex-1">
<div className="pr-4">
{sections.map((section) => {
const SectionComponent = section.component;
return (
<div
key={section.key}
className={cn(
activeSection === section.key ? "block" : "hidden",
)}
>
<SectionComponent
level="camera"
cameraName={cameraName}
showOverrideIndicator
onSave={onSave}
/>
</div>
);
})}
</div>
</ScrollArea>
</div>
);
}

View File

@ -0,0 +1,328 @@
// Global Configuration View
// Main view for configuring global Frigate settings
import { useMemo, useCallback, useState } from "react";
import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { ConfigForm } from "@/components/config-form/ConfigForm";
import {
DetectSection,
RecordSection,
SnapshotsSection,
MotionSection,
ObjectsSection,
ReviewSection,
AudioSection,
NotificationsSection,
LiveSection,
TimestampSection,
} from "@/components/config-form/sections";
import type { RJSFSchema } from "@rjsf/utils";
import type { FrigateConfig } from "@/types/frigateConfig";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { extractSchemaSection } from "@/lib/config-schema";
import ActivityIndicator from "@/components/indicators/activity-indicator";
// Section configurations for global-only settings
const globalSectionConfigs: Record<
string,
{ fieldOrder?: string[]; hiddenFields?: string[]; advancedFields?: string[] }
> = {
mqtt: {
fieldOrder: [
"enabled",
"host",
"port",
"user",
"password",
"topic_prefix",
"client_id",
"stats_interval",
"tls_ca_certs",
"tls_client_cert",
"tls_client_key",
"tls_insecure",
],
advancedFields: [
"stats_interval",
"tls_ca_certs",
"tls_client_cert",
"tls_client_key",
"tls_insecure",
],
},
database: {
fieldOrder: ["path"],
advancedFields: [],
},
auth: {
fieldOrder: [
"enabled",
"reset_admin_password",
"native_oauth_url",
"failed_login_rate_limit",
"trusted_proxies",
],
advancedFields: ["failed_login_rate_limit", "trusted_proxies"],
},
tls: {
fieldOrder: ["enabled", "cert", "key"],
advancedFields: [],
},
telemetry: {
fieldOrder: ["network_interfaces", "stats", "version_check"],
advancedFields: ["stats"],
},
birdseye: {
fieldOrder: [
"enabled",
"restream",
"width",
"height",
"quality",
"mode",
"layout",
"inactivity_threshold",
],
advancedFields: ["width", "height", "quality", "inactivity_threshold"],
},
semantic_search: {
fieldOrder: ["enabled", "reindex", "model_size"],
advancedFields: ["reindex"],
},
face_recognition: {
fieldOrder: ["enabled", "threshold", "min_area", "model_size"],
advancedFields: ["threshold", "min_area"],
},
lpr: {
fieldOrder: [
"enabled",
"threshold",
"min_area",
"min_ratio",
"max_ratio",
"model_size",
],
advancedFields: ["threshold", "min_area", "min_ratio", "max_ratio"],
},
};
interface GlobalConfigSectionProps {
sectionKey: string;
schema: RJSFSchema | null;
config: FrigateConfig | undefined;
onSave: () => void;
}
function GlobalConfigSection({
sectionKey,
schema,
config,
onSave,
}: GlobalConfigSectionProps) {
const { t } = useTranslation(["views/settings"]);
const formData = useMemo((): Record<string, unknown> => {
if (!config) return {} as Record<string, unknown>;
const value = (config as unknown as Record<string, unknown>)[sectionKey];
return (
(value as Record<string, unknown>) || ({} as Record<string, unknown>)
);
}, [config, sectionKey]);
const handleSubmit = useCallback(
async (data: Record<string, unknown>) => {
try {
await axios.put("config/set", {
requires_restart: 1,
config_data: {
[sectionKey]: data,
},
});
toast.success(
t(`configForm.${sectionKey}.toast.success`, {
defaultValue: "Settings saved successfully",
}),
);
onSave();
} catch (error) {
toast.error(
t(`configForm.${sectionKey}.toast.error`, {
defaultValue: "Failed to save settings",
}),
);
}
},
[sectionKey, t, onSave],
);
if (!schema) {
return null;
}
const sectionConfig = globalSectionConfigs[sectionKey] || {};
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">
{t(`configForm.${sectionKey}.title`, {
defaultValue:
sectionKey.charAt(0).toUpperCase() + sectionKey.slice(1),
})}
</CardTitle>
</CardHeader>
<CardContent>
<ConfigForm
schema={schema}
formData={formData}
onSubmit={handleSubmit}
fieldOrder={sectionConfig.fieldOrder}
hiddenFields={sectionConfig.hiddenFields}
advancedFields={sectionConfig.advancedFields}
/>
</CardContent>
</Card>
);
}
export default function GlobalConfigView() {
const { t } = useTranslation(["views/settings"]);
const [activeTab, setActiveTab] = useState("shared");
const { data: config, mutate: refreshConfig } =
useSWR<FrigateConfig>("config");
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
const handleSave = useCallback(() => {
refreshConfig();
}, [refreshConfig]);
if (!config || !schema) {
return (
<div className="flex h-full items-center justify-center">
<ActivityIndicator />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">
{t("configForm.global.title", {
defaultValue: "Global Configuration",
})}
</h2>
<p className="text-muted-foreground">
{t("configForm.global.description", {
defaultValue:
"Configure global settings that apply to all cameras by default.",
})}
</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="shared">
{t("configForm.global.tabs.shared", {
defaultValue: "Shared Defaults",
})}
</TabsTrigger>
<TabsTrigger value="system">
{t("configForm.global.tabs.system", { defaultValue: "System" })}
</TabsTrigger>
<TabsTrigger value="integrations">
{t("configForm.global.tabs.integrations", {
defaultValue: "Integrations",
})}
</TabsTrigger>
</TabsList>
<ScrollArea className="h-[calc(100vh-300px)]">
<TabsContent value="shared" className="space-y-6 p-1">
{/* Shared config sections - these can be overridden per camera */}
<DetectSection level="global" onSave={handleSave} />
<RecordSection level="global" onSave={handleSave} />
<SnapshotsSection level="global" onSave={handleSave} />
<MotionSection level="global" onSave={handleSave} />
<ObjectsSection level="global" onSave={handleSave} />
<ReviewSection level="global" onSave={handleSave} />
<AudioSection level="global" onSave={handleSave} />
<NotificationsSection level="global" onSave={handleSave} />
<LiveSection level="global" onSave={handleSave} />
<TimestampSection level="global" onSave={handleSave} />
</TabsContent>
<TabsContent value="system" className="space-y-6 p-1">
{/* System configuration sections */}
<GlobalConfigSection
sectionKey="database"
schema={extractSchemaSection(schema, "database")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="tls"
schema={extractSchemaSection(schema, "tls")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="auth"
schema={extractSchemaSection(schema, "auth")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="telemetry"
schema={extractSchemaSection(schema, "telemetry")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="birdseye"
schema={extractSchemaSection(schema, "birdseye")}
config={config}
onSave={handleSave}
/>
</TabsContent>
<TabsContent value="integrations" className="space-y-6 p-1">
{/* Integration configuration sections */}
<GlobalConfigSection
sectionKey="mqtt"
schema={extractSchemaSection(schema, "mqtt")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="semantic_search"
schema={extractSchemaSection(schema, "semantic_search")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="face_recognition"
schema={extractSchemaSection(schema, "face_recognition")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="lpr"
schema={extractSchemaSection(schema, "lpr")}
config={config}
onSave={handleSave}
/>
</TabsContent>
</ScrollArea>
</Tabs>
</div>
);
}

View File

@ -4,7 +4,7 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import monacoEditorPlugin from "vite-plugin-monaco-editor";
const proxyHost = process.env.PROXY_HOST || "1ocalhost:5000";
const proxyHost = process.env.PROXY_HOST || "localhost:5000";
// https://vitejs.dev/config/
export default defineConfig({