mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
use react-jsonschema-form for UI config
This commit is contained in:
parent
eeefbf2bb5
commit
68c74fef05
488
.tasks/ui-config-rjsf/task.md
Normal file
488
.tasks/ui-config-rjsf/task.md
Normal 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
207
web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
157
web/src/components/config-form/ConfigForm.tsx
Normal file
157
web/src/components/config-form/ConfigForm.tsx
Normal 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;
|
||||
30
web/src/components/config-form/sections/AudioSection.tsx
Normal file
30
web/src/components/config-form/sections/AudioSection.tsx
Normal 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;
|
||||
249
web/src/components/config-form/sections/BaseSection.tsx
Normal file
249
web/src/components/config-form/sections/BaseSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
}
|
||||
37
web/src/components/config-form/sections/DetectSection.tsx
Normal file
37
web/src/components/config-form/sections/DetectSection.tsx
Normal 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;
|
||||
20
web/src/components/config-form/sections/LiveSection.tsx
Normal file
20
web/src/components/config-form/sections/LiveSection.tsx
Normal 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;
|
||||
42
web/src/components/config-form/sections/MotionSection.tsx
Normal file
42
web/src/components/config-form/sections/MotionSection.tsx
Normal 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;
|
||||
@ -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;
|
||||
23
web/src/components/config-form/sections/ObjectsSection.tsx
Normal file
23
web/src/components/config-form/sections/ObjectsSection.tsx
Normal 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;
|
||||
32
web/src/components/config-form/sections/RecordSection.tsx
Normal file
32
web/src/components/config-form/sections/RecordSection.tsx
Normal 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;
|
||||
20
web/src/components/config-form/sections/ReviewSection.tsx
Normal file
20
web/src/components/config-form/sections/ReviewSection.tsx
Normal 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;
|
||||
29
web/src/components/config-form/sections/SnapshotsSection.tsx
Normal file
29
web/src/components/config-form/sections/SnapshotsSection.tsx
Normal 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;
|
||||
22
web/src/components/config-form/sections/TimestampSection.tsx
Normal file
22
web/src/components/config-form/sections/TimestampSection.tsx
Normal 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;
|
||||
23
web/src/components/config-form/sections/index.ts
Normal file
23
web/src/components/config-form/sections/index.ts
Normal 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";
|
||||
67
web/src/components/config-form/theme/fields/nullableUtils.ts
Normal file
67
web/src/components/config-form/theme/fields/nullableUtils.ts
Normal 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
|
||||
);
|
||||
}
|
||||
76
web/src/components/config-form/theme/frigateTheme.ts
Normal file
76
web/src/components/config-form/theme/frigateTheme.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
5
web/src/components/config-form/theme/index.ts
Normal file
5
web/src/components/config-form/theme/index.ts
Normal 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";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
53
web/src/components/config-form/theme/widgets/ColorWidget.tsx
Normal file
53
web/src/components/config-form/theme/widgets/ColorWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
31
web/src/components/config-form/theme/widgets/RangeWidget.tsx
Normal file
31
web/src/components/config-form/theme/widgets/RangeWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
74
web/src/components/config-form/theme/widgets/TagsWidget.tsx
Normal file
74
web/src/components/config-form/theme/widgets/TagsWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
web/src/components/config-form/theme/widgets/TextWidget.tsx
Normal file
34
web/src/components/config-form/theme/widgets/TextWidget.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
182
web/src/hooks/use-config-override.ts
Normal file
182
web/src/hooks/use-config-override.ts
Normal 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]);
|
||||
}
|
||||
128
web/src/hooks/use-config-schema.ts
Normal file
128
web/src/hooks/use-config-schema.ts
Normal 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;
|
||||
}
|
||||
99
web/src/lib/config-schema/errorMessages.ts
Normal file
99
web/src/lib/config-schema/errorMessages.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
19
web/src/lib/config-schema/index.ts
Normal file
19
web/src/lib/config-schema/index.ts
Normal 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";
|
||||
447
web/src/lib/config-schema/transformer.ts
Normal file
447
web/src/lib/config-schema/transformer.ts
Normal 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;
|
||||
}
|
||||
@ -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",
|
||||
|
||||
265
web/src/views/settings/CameraConfigView.tsx
Normal file
265
web/src/views/settings/CameraConfigView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
328
web/src/views/settings/GlobalConfigView.tsx
Normal file
328
web/src/views/settings/GlobalConfigView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user