mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-29 15:19:00 +03:00
Full UI configuration (#22151)
* use react-jsonschema-form for UI config * don't use properties wrapper when generating config i18n json * configure for full i18n support * section fields * add descriptions to all fields for i18n * motion i18n * fix nullable fields * sanitize internal fields * add switches widgets and use friendly names * fix nullable schema entries * ensure update_topic is added to api calls this needs further backend implementation to work correctly * add global sections, camera config overrides, and reset button * i18n * add reset logic to global config view * tweaks * fix sections and live validation * fix validation for schema objects that can be null * generic and custom per-field validation * improve generic error validation messages * remove show advanced fields switch * tweaks * use shadcn theme * fix array field template * i18n tweaks * remove collapsible around root section * deep merge schema for advanced fields * add array field item template and fix ffmpeg section * add missing i18n keys * tweaks * comment out api call for testing * add config groups as a separate i18n namespace * add descriptions to all pydantic fields * make titles more concise * new titles as i18n * update i18n config generation script to use json schema * tweaks * tweaks * rebase * clean up * form tweaks * add wildcards and fix object filter fields * add field template for additionalproperties schema objects * improve typing * add section description from schema and clarify global vs camera level descriptions * separate and consolidate global and camera i18n namespaces * clean up now obsolete namespaces * tweaks * refactor sections and overrides * add ability to render components before and after fields * fix titles * chore(sections): remove legacy single-section components replaced by template * refactor configs to use individual files with a template * fix review description * apply hidden fields after ui schema * move util * remove unused i18n * clean up error messages * fix fast refresh * add custom validation and use it for ffmpeg input roles * update nav tree * remove unused * re-add override and modified indicators * mark pending changes and add confirmation dialog for resets * fix red unsaved dot * tweaks * add docs links, readonly keys, and restart required per field * add special case and comments for global motion section * add section form special cases * combine review sections * tweaks * add audio labels endpoint * add audio label switches and input to filter list * fix type * remove key from config when resetting to default/global * don't show description for new key/val fields * tweaks * spacing tweaks * add activity indicator and scrollbar tweaks * add docs to filter fields * wording changes * fix global ffmpeg section * add review classification zones to review form * add backend endpoint and frontend widget for ffmpeg presets and manual args * improve wording * hide descriptions for additional properties arrays * add warning log about incorrectly nested model config * spacing and language tweaks * fix i18n keys * networking section docs and description * small wording tweaks * add layout grid field * refactor with shared utilities * field order * add individual detectors to schema add detector titles and descriptions (docstrings in pydantic are used for descriptions) and add i18n keys to globals * clean up detectors section and i18n * don't save model config back to yaml when saving detectors * add full detectors config to api model dump works around the way we use detector plugins so we can have the full detector config for the frontend * add restart button to toast when restart is required * add ui option to remove inner cards * fix buttons * section tweaks * don't zoom into text on mobile * make buttons sticky at bottom of sections * small tweaks * highlight label of changed fields * add null to enum list when unwrapping * refactor to shared utils and add save all button * add undo all button * add RJSF to dictionary * consolidate utils * preserve form data when changing cameras * add mono fonts * add popover to show what fields will be saved * fix mobile menu not re-rendering with unsaved dots * tweaks * fix logger and env vars config section saving use escaped periods in keys to retain them in the config file (eg "frigate.embeddings") * add timezone widget * role map field with validation * fix validation for model section * add another hidden field * add footer message for required restart * use rjsf for notifications view * fix config saving * add replace rules field * default column layout and add field sizing * clean up field template * refactor profile settings to match rjsf forms * tweaks * refactor frigate+ view and make tweaks to sections * show frigate+ model info in detection model settings when using a frigate+ model * update restartRequired for all fields * fix restart fields * tweaks and add ability enable disabled cameras more backend changes required * require restart when enabling camera that is disabled in config * disable save when form is invalid * refactor ffmpeg section for readability * change label * clean up camera inputs fields * misc tweaks to ffmpeg section - add raw paths endpoint to ensure credentials get saved - restart required tooltip * maintenance settings tweaks * don't mutate with lodash * fix description re-rendering for nullable object fields * hide reindex field * update rjsf * add frigate+ description to settings pane * disable save all when any section is invalid * show translated field name in validation error pane * clean up * remove unused * fix genai merge * fix genai
This commit is contained in:
Generated
+1408
-7
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,10 @@
|
||||
"@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.3.1",
|
||||
"@rjsf/shadcn": "^6.3.1",
|
||||
"@rjsf/utils": "^6.3.1",
|
||||
"@rjsf/validator-ajv8": "^6.3.1",
|
||||
"apexcharts": "^3.52.0",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -115,8 +115,10 @@
|
||||
"internalID": "The Internal ID Frigate uses in the configuration and database"
|
||||
},
|
||||
"button": {
|
||||
"add": "Add",
|
||||
"apply": "Apply",
|
||||
"reset": "Reset",
|
||||
"undo": "Undo",
|
||||
"done": "Done",
|
||||
"enabled": "Enabled",
|
||||
"enable": "Enable",
|
||||
@@ -150,7 +152,14 @@
|
||||
"export": "Export",
|
||||
"deleteNow": "Delete Now",
|
||||
"next": "Next",
|
||||
"continue": "Continue"
|
||||
"continue": "Continue",
|
||||
"modified": "Modified",
|
||||
"overridden": "Overridden",
|
||||
"resetToGlobal": "Reset to Global",
|
||||
"resetToDefault": "Reset to Default",
|
||||
"saveAll": "Save All",
|
||||
"savingAll": "Saving All…",
|
||||
"undoAll": "Undo All"
|
||||
},
|
||||
"menu": {
|
||||
"system": "System",
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"label": "Global Audio events configuration.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable audio events."
|
||||
},
|
||||
"max_not_heard": {
|
||||
"label": "Seconds of not hearing the type of audio to end the event."
|
||||
},
|
||||
"min_volume": {
|
||||
"label": "Min volume required to run audio detection."
|
||||
},
|
||||
"listen": {
|
||||
"label": "Audio to listen for."
|
||||
},
|
||||
"filters": {
|
||||
"label": "Audio filters."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Keep track of original state of audio detection."
|
||||
},
|
||||
"num_threads": {
|
||||
"label": "Number of detection threads"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"label": "Audio transcription config.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable audio transcription."
|
||||
},
|
||||
"language": {
|
||||
"label": "Language abbreviation to use for audio event transcription/translation."
|
||||
},
|
||||
"device": {
|
||||
"label": "The device used for license plate recognition."
|
||||
},
|
||||
"model_size": {
|
||||
"label": "The size of the embeddings model used."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Keep track of original state of camera."
|
||||
},
|
||||
"live_enabled": {
|
||||
"label": "Enable live transcriptions."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"label": "Auth configuration.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable authentication"
|
||||
},
|
||||
"reset_admin_password": {
|
||||
"label": "Reset the admin password on startup"
|
||||
},
|
||||
"cookie_name": {
|
||||
"label": "Name for jwt token cookie"
|
||||
},
|
||||
"cookie_secure": {
|
||||
"label": "Set secure flag on cookie"
|
||||
},
|
||||
"session_length": {
|
||||
"label": "Session length for jwt session tokens"
|
||||
},
|
||||
"refresh_time": {
|
||||
"label": "Refresh the session if it is going to expire in this many seconds"
|
||||
},
|
||||
"failed_login_rate_limit": {
|
||||
"label": "Rate limits for failed login attempts."
|
||||
},
|
||||
"trusted_proxies": {
|
||||
"label": "Trusted proxies for determining IP address to rate limit"
|
||||
},
|
||||
"hash_iterations": {
|
||||
"label": "Password hash iterations"
|
||||
},
|
||||
"roles": {
|
||||
"label": "Role to camera mappings. Empty list grants access to all cameras."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"label": "Birdseye configuration.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable birdseye view."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Tracking mode."
|
||||
},
|
||||
"restream": {
|
||||
"label": "Restream birdseye via RTSP."
|
||||
},
|
||||
"width": {
|
||||
"label": "Birdseye width."
|
||||
},
|
||||
"height": {
|
||||
"label": "Birdseye height."
|
||||
},
|
||||
"quality": {
|
||||
"label": "Encoding quality."
|
||||
},
|
||||
"inactivity_threshold": {
|
||||
"label": "Birdseye Inactivity Threshold"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Birdseye Layout Config",
|
||||
"properties": {
|
||||
"scaling_factor": {
|
||||
"label": "Birdseye Scaling Factor"
|
||||
},
|
||||
"max_cameras": {
|
||||
"label": "Max cameras"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"label": "Camera group configuration",
|
||||
"properties": {
|
||||
"cameras": {
|
||||
"label": "List of cameras in this group."
|
||||
},
|
||||
"icon": {
|
||||
"label": "Icon that represents camera group."
|
||||
},
|
||||
"order": {
|
||||
"label": "Sort order for group."
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"label": "Object classification config.",
|
||||
"properties": {
|
||||
"bird": {
|
||||
"label": "Bird classification config.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable bird classification."
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Minimum classification score required to be considered a match."
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"label": "Custom Classification Model Configs.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable running the model."
|
||||
},
|
||||
"name": {
|
||||
"label": "Name of classification model."
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Classification score threshold to change the state."
|
||||
},
|
||||
"object_config": {
|
||||
"properties": {
|
||||
"objects": {
|
||||
"label": "Object types to classify."
|
||||
},
|
||||
"classification_type": {
|
||||
"label": "Type of classification that is applied."
|
||||
}
|
||||
}
|
||||
},
|
||||
"state_config": {
|
||||
"properties": {
|
||||
"cameras": {
|
||||
"label": "Cameras to run classification on.",
|
||||
"properties": {
|
||||
"crop": {
|
||||
"label": "Crop of image frame on this camera to run classification on."
|
||||
}
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"label": "If classification should be run when motion is detected in the crop."
|
||||
},
|
||||
"interval": {
|
||||
"label": "Interval to run classification on in seconds."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"label": "Database configuration.",
|
||||
"properties": {
|
||||
"path": {
|
||||
"label": "Database path."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"label": "Global object tracking configuration.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Detection Enabled."
|
||||
},
|
||||
"height": {
|
||||
"label": "Height of the stream for the detect role."
|
||||
},
|
||||
"width": {
|
||||
"label": "Width of the stream for the detect role."
|
||||
},
|
||||
"fps": {
|
||||
"label": "Number of frames per second to process through detection."
|
||||
},
|
||||
"min_initialized": {
|
||||
"label": "Minimum number of consecutive hits for an object to be initialized by the tracker."
|
||||
},
|
||||
"max_disappeared": {
|
||||
"label": "Maximum number of frames the object can disappear before detection ends."
|
||||
},
|
||||
"stationary": {
|
||||
"label": "Stationary objects config.",
|
||||
"properties": {
|
||||
"interval": {
|
||||
"label": "Frame interval for checking stationary objects."
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Number of frames without a position change for an object to be considered stationary"
|
||||
},
|
||||
"max_frames": {
|
||||
"label": "Max frames for stationary objects.",
|
||||
"properties": {
|
||||
"default": {
|
||||
"label": "Default max frames."
|
||||
},
|
||||
"objects": {
|
||||
"label": "Object specific max frames."
|
||||
}
|
||||
}
|
||||
},
|
||||
"classifier": {
|
||||
"label": "Enable visual classifier for determing if objects with jittery bounding boxes are stationary."
|
||||
}
|
||||
}
|
||||
},
|
||||
"annotation_offset": {
|
||||
"label": "Milliseconds to offset detect annotations by."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"label": "Detector hardware configuration.",
|
||||
"properties": {
|
||||
"type": {
|
||||
"label": "Detector Type"
|
||||
},
|
||||
"model": {
|
||||
"label": "Detector specific model configuration."
|
||||
},
|
||||
"model_path": {
|
||||
"label": "Detector specific model path."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"label": "Frigate environment variables."
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"label": "Face recognition config.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable face recognition."
|
||||
},
|
||||
"model_size": {
|
||||
"label": "The size of the embeddings model used."
|
||||
},
|
||||
"unknown_score": {
|
||||
"label": "Minimum face distance score required to be marked as a potential match."
|
||||
},
|
||||
"detection_threshold": {
|
||||
"label": "Minimum face detection score required to be considered a face."
|
||||
},
|
||||
"recognition_threshold": {
|
||||
"label": "Minimum face distance score required to be considered a match."
|
||||
},
|
||||
"min_area": {
|
||||
"label": "Min area of face box to consider running face recognition."
|
||||
},
|
||||
"min_faces": {
|
||||
"label": "Min face recognitions for the sub label to be applied to the person object."
|
||||
},
|
||||
"save_attempts": {
|
||||
"label": "Number of face attempts to save in the recent recognitions tab."
|
||||
},
|
||||
"blur_confidence_filter": {
|
||||
"label": "Apply blur quality filter to face confidence."
|
||||
},
|
||||
"device": {
|
||||
"label": "The device key to use for face recognition.",
|
||||
"description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"label": "Global FFmpeg configuration.",
|
||||
"properties": {
|
||||
"path": {
|
||||
"label": "FFmpeg path"
|
||||
},
|
||||
"global_args": {
|
||||
"label": "Global FFmpeg arguments."
|
||||
},
|
||||
"hwaccel_args": {
|
||||
"label": "FFmpeg hardware acceleration arguments."
|
||||
},
|
||||
"input_args": {
|
||||
"label": "FFmpeg input arguments."
|
||||
},
|
||||
"output_args": {
|
||||
"label": "FFmpeg output arguments per role.",
|
||||
"properties": {
|
||||
"detect": {
|
||||
"label": "Detect role FFmpeg output arguments."
|
||||
},
|
||||
"record": {
|
||||
"label": "Record role FFmpeg output arguments."
|
||||
}
|
||||
}
|
||||
},
|
||||
"retry_interval": {
|
||||
"label": "Time in seconds to wait before FFmpeg retries connecting to the camera."
|
||||
},
|
||||
"apple_compatibility": {
|
||||
"label": "Set tag on HEVC (H.265) recording stream to improve compatibility with Apple players."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"label": "Generative AI configuration.",
|
||||
"properties": {
|
||||
"api_key": {
|
||||
"label": "Provider API key."
|
||||
},
|
||||
"base_url": {
|
||||
"label": "Provider base url."
|
||||
},
|
||||
"model": {
|
||||
"label": "GenAI model."
|
||||
},
|
||||
"provider": {
|
||||
"label": "GenAI provider."
|
||||
},
|
||||
"provider_options": {
|
||||
"label": "GenAI Provider extra options."
|
||||
},
|
||||
"runtime_options": {
|
||||
"label": "Options to pass during inference calls."
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"label": "Global restream configuration."
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"audio": {
|
||||
"global": {
|
||||
"detection": "Global Detection",
|
||||
"sensitivity": "Global Sensitivity"
|
||||
},
|
||||
"cameras": {
|
||||
"detection": "Detection",
|
||||
"sensitivity": "Sensitivity"
|
||||
}
|
||||
},
|
||||
"timestamp_style": {
|
||||
"global": {
|
||||
"appearance": "Global Appearance"
|
||||
},
|
||||
"cameras": {
|
||||
"appearance": "Appearance"
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"global": {
|
||||
"sensitivity": "Global Sensitivity",
|
||||
"algorithm": "Global Algorithm"
|
||||
},
|
||||
"cameras": {
|
||||
"sensitivity": "Sensitivity",
|
||||
"algorithm": "Algorithm"
|
||||
}
|
||||
},
|
||||
"snapshots": {
|
||||
"global": {
|
||||
"display": "Global Display"
|
||||
},
|
||||
"cameras": {
|
||||
"display": "Display"
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"global": {
|
||||
"resolution": "Global Resolution",
|
||||
"tracking": "Global Tracking"
|
||||
},
|
||||
"cameras": {
|
||||
"resolution": "Resolution",
|
||||
"tracking": "Tracking"
|
||||
}
|
||||
},
|
||||
"objects": {
|
||||
"global": {
|
||||
"tracking": "Global Tracking",
|
||||
"filtering": "Global Filtering"
|
||||
},
|
||||
"cameras": {
|
||||
"tracking": "Tracking",
|
||||
"filtering": "Filtering"
|
||||
}
|
||||
},
|
||||
"record": {
|
||||
"global": {
|
||||
"retention": "Global Retention",
|
||||
"events": "Global Events"
|
||||
},
|
||||
"cameras": {
|
||||
"retention": "Retention",
|
||||
"events": "Events"
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"cameras": {
|
||||
"cameraFfmpeg": "Camera-specific FFmpeg arguments"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"label": "Live playback settings.",
|
||||
"properties": {
|
||||
"streams": {
|
||||
"label": "Friendly names and restream names to use for live view."
|
||||
},
|
||||
"height": {
|
||||
"label": "Live camera view height"
|
||||
},
|
||||
"quality": {
|
||||
"label": "Live camera view quality"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"label": "Logging configuration.",
|
||||
"properties": {
|
||||
"default": {
|
||||
"label": "Default logging level."
|
||||
},
|
||||
"logs": {
|
||||
"label": "Log level for specified processes."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"label": "License Plate recognition config.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable license plate recognition."
|
||||
},
|
||||
"model_size": {
|
||||
"label": "The size of the embeddings model used."
|
||||
},
|
||||
"detection_threshold": {
|
||||
"label": "License plate object confidence score required to begin running recognition."
|
||||
},
|
||||
"min_area": {
|
||||
"label": "Minimum area of license plate to begin running recognition."
|
||||
},
|
||||
"recognition_threshold": {
|
||||
"label": "Recognition confidence score required to add the plate to the object as a sub label."
|
||||
},
|
||||
"min_plate_length": {
|
||||
"label": "Minimum number of characters a license plate must have to be added to the object as a sub label."
|
||||
},
|
||||
"format": {
|
||||
"label": "Regular expression for the expected format of license plate."
|
||||
},
|
||||
"match_distance": {
|
||||
"label": "Allow this number of missing/incorrect characters to still cause a detected plate to match a known plate."
|
||||
},
|
||||
"known_plates": {
|
||||
"label": "Known plates to track (strings or regular expressions)."
|
||||
},
|
||||
"enhancement": {
|
||||
"label": "Amount of contrast adjustment and denoising to apply to license plate images before recognition."
|
||||
},
|
||||
"debug_save_plates": {
|
||||
"label": "Save plates captured for LPR for debugging purposes."
|
||||
},
|
||||
"device": {
|
||||
"label": "The device key to use for LPR.",
|
||||
"description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information"
|
||||
},
|
||||
"replace_rules": {
|
||||
"label": "List of regex replacement rules for normalizing detected plates. Each rule has 'pattern' and 'replacement'."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"label": "Detection model configuration.",
|
||||
"properties": {
|
||||
"path": {
|
||||
"label": "Custom Object detection model path."
|
||||
},
|
||||
"labelmap_path": {
|
||||
"label": "Label map for custom object detector."
|
||||
},
|
||||
"width": {
|
||||
"label": "Object detection model input width."
|
||||
},
|
||||
"height": {
|
||||
"label": "Object detection model input height."
|
||||
},
|
||||
"labelmap": {
|
||||
"label": "Labelmap customization."
|
||||
},
|
||||
"attributes_map": {
|
||||
"label": "Map of object labels to their attribute labels."
|
||||
},
|
||||
"input_tensor": {
|
||||
"label": "Model Input Tensor Shape"
|
||||
},
|
||||
"input_pixel_format": {
|
||||
"label": "Model Input Pixel Color Format"
|
||||
},
|
||||
"input_dtype": {
|
||||
"label": "Model Input D Type"
|
||||
},
|
||||
"model_type": {
|
||||
"label": "Object Detection Model Type"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"label": "Global motion detection configuration."
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"label": "MQTT configuration.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable MQTT Communication."
|
||||
},
|
||||
"host": {
|
||||
"label": "MQTT Host"
|
||||
},
|
||||
"port": {
|
||||
"label": "MQTT Port"
|
||||
},
|
||||
"topic_prefix": {
|
||||
"label": "MQTT Topic Prefix"
|
||||
},
|
||||
"client_id": {
|
||||
"label": "MQTT Client ID"
|
||||
},
|
||||
"stats_interval": {
|
||||
"label": "MQTT Camera Stats Interval"
|
||||
},
|
||||
"user": {
|
||||
"label": "MQTT Username"
|
||||
},
|
||||
"password": {
|
||||
"label": "MQTT Password"
|
||||
},
|
||||
"tls_ca_certs": {
|
||||
"label": "MQTT TLS CA Certificates"
|
||||
},
|
||||
"tls_client_cert": {
|
||||
"label": "MQTT TLS Client Certificate"
|
||||
},
|
||||
"tls_client_key": {
|
||||
"label": "MQTT TLS Client Key"
|
||||
},
|
||||
"tls_insecure": {
|
||||
"label": "MQTT TLS Insecure"
|
||||
},
|
||||
"qos": {
|
||||
"label": "MQTT QoS"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"label": "Networking configuration",
|
||||
"properties": {
|
||||
"ipv6": {
|
||||
"label": "IPv6 configuration",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable IPv6 for port 5000 and/or 8971"
|
||||
}
|
||||
}
|
||||
},
|
||||
"listen": {
|
||||
"label": "Listening ports configuration",
|
||||
"properties": {
|
||||
"internal": {
|
||||
"label": "Internal listening port for Frigate"
|
||||
},
|
||||
"external": {
|
||||
"label": "External listening port for Frigate"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"label": "Global notification configuration.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable notifications"
|
||||
},
|
||||
"email": {
|
||||
"label": "Email required for push."
|
||||
},
|
||||
"cooldown": {
|
||||
"label": "Cooldown period for notifications (time in seconds)."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Keep track of original state of notifications."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
{
|
||||
"label": "Global object configuration.",
|
||||
"properties": {
|
||||
"track": {
|
||||
"label": "Objects to track."
|
||||
},
|
||||
"filters": {
|
||||
"label": "Object filters.",
|
||||
"properties": {
|
||||
"min_area": {
|
||||
"label": "Minimum area of bounding box for object to be counted. Can be pixels (int) or percentage (float between 0.000001 and 0.99)."
|
||||
},
|
||||
"max_area": {
|
||||
"label": "Maximum area of bounding box for object to be counted. Can be pixels (int) or percentage (float between 0.000001 and 0.99)."
|
||||
},
|
||||
"min_ratio": {
|
||||
"label": "Minimum ratio of bounding box's width/height for object to be counted."
|
||||
},
|
||||
"max_ratio": {
|
||||
"label": "Maximum ratio of bounding box's width/height for object to be counted."
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Average detection confidence threshold for object to be counted."
|
||||
},
|
||||
"min_score": {
|
||||
"label": "Minimum detection confidence for object to be counted."
|
||||
},
|
||||
"mask": {
|
||||
"label": "Detection area polygon mask for this filter configuration."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mask": {
|
||||
"label": "Object mask."
|
||||
},
|
||||
"genai": {
|
||||
"label": "Config for using genai to analyze objects.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable GenAI for camera."
|
||||
},
|
||||
"use_snapshot": {
|
||||
"label": "Use snapshots for generating descriptions."
|
||||
},
|
||||
"prompt": {
|
||||
"label": "Default caption prompt."
|
||||
},
|
||||
"object_prompts": {
|
||||
"label": "Object specific prompts."
|
||||
},
|
||||
"objects": {
|
||||
"label": "List of objects to run generative AI for."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "List of required zones to be entered in order to run generative AI."
|
||||
},
|
||||
"debug_save_thumbnails": {
|
||||
"label": "Save thumbnails sent to generative AI for debugging purposes."
|
||||
},
|
||||
"send_triggers": {
|
||||
"label": "What triggers to use to send frames to generative AI for a tracked object.",
|
||||
"properties": {
|
||||
"tracked_object_end": {
|
||||
"label": "Send once the object is no longer tracked."
|
||||
},
|
||||
"after_significant_updates": {
|
||||
"label": "Send an early request to generative AI when X frames accumulated."
|
||||
}
|
||||
}
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Keep track of original state of generative AI."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"label": "Proxy configuration.",
|
||||
"properties": {
|
||||
"header_map": {
|
||||
"label": "Header mapping definitions for proxy user passing.",
|
||||
"properties": {
|
||||
"user": {
|
||||
"label": "Header name from upstream proxy to identify user."
|
||||
},
|
||||
"role": {
|
||||
"label": "Header name from upstream proxy to identify user role."
|
||||
},
|
||||
"role_map": {
|
||||
"label": "Mapping of Frigate roles to upstream group values. "
|
||||
}
|
||||
}
|
||||
},
|
||||
"logout_url": {
|
||||
"label": "Redirect url for logging out with proxy."
|
||||
},
|
||||
"auth_secret": {
|
||||
"label": "Secret value for proxy authentication."
|
||||
},
|
||||
"default_role": {
|
||||
"label": "Default role for proxy users."
|
||||
},
|
||||
"separator": {
|
||||
"label": "The character used to separate values in a mapped header."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
{
|
||||
"label": "Global record configuration.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable record on all cameras."
|
||||
},
|
||||
"expire_interval": {
|
||||
"label": "Number of minutes to wait between cleanup runs."
|
||||
},
|
||||
"continuous": {
|
||||
"label": "Continuous recording retention settings.",
|
||||
"properties": {
|
||||
"days": {
|
||||
"label": "Default retention period."
|
||||
}
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"label": "Motion recording retention settings.",
|
||||
"properties": {
|
||||
"days": {
|
||||
"label": "Default retention period."
|
||||
}
|
||||
}
|
||||
},
|
||||
"detections": {
|
||||
"label": "Detection specific retention settings.",
|
||||
"properties": {
|
||||
"pre_capture": {
|
||||
"label": "Seconds to retain before event starts."
|
||||
},
|
||||
"post_capture": {
|
||||
"label": "Seconds to retain after event ends."
|
||||
},
|
||||
"retain": {
|
||||
"label": "Event retention settings.",
|
||||
"properties": {
|
||||
"days": {
|
||||
"label": "Default retention period."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Retain mode."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"alerts": {
|
||||
"label": "Alert specific retention settings.",
|
||||
"properties": {
|
||||
"pre_capture": {
|
||||
"label": "Seconds to retain before event starts."
|
||||
},
|
||||
"post_capture": {
|
||||
"label": "Seconds to retain after event ends."
|
||||
},
|
||||
"retain": {
|
||||
"label": "Event retention settings.",
|
||||
"properties": {
|
||||
"days": {
|
||||
"label": "Default retention period."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Retain mode."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"export": {
|
||||
"label": "Recording Export Config",
|
||||
"properties": {
|
||||
"timelapse_args": {
|
||||
"label": "Timelapse Args"
|
||||
}
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"label": "Recording Preview Config",
|
||||
"properties": {
|
||||
"quality": {
|
||||
"label": "Quality of recording preview."
|
||||
}
|
||||
}
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Keep track of original state of recording."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"label": "Review configuration.",
|
||||
"properties": {
|
||||
"alerts": {
|
||||
"label": "Review alerts config.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable alerts."
|
||||
},
|
||||
"labels": {
|
||||
"label": "Labels to create alerts for."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "List of required zones to be entered in order to save the event as an alert."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Keep track of original state of alerts."
|
||||
},
|
||||
"cutoff_time": {
|
||||
"label": "Time to cutoff alerts after no alert-causing activity has occurred."
|
||||
}
|
||||
}
|
||||
},
|
||||
"detections": {
|
||||
"label": "Review detections config.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable detections."
|
||||
},
|
||||
"labels": {
|
||||
"label": "Labels to create detections for."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "List of required zones to be entered in order to save the event as a detection."
|
||||
},
|
||||
"cutoff_time": {
|
||||
"label": "Time to cutoff detection after no detection-causing activity has occurred."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Keep track of original state of detections."
|
||||
}
|
||||
}
|
||||
},
|
||||
"genai": {
|
||||
"label": "Review description genai config.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable GenAI descriptions for review items."
|
||||
},
|
||||
"alerts": {
|
||||
"label": "Enable GenAI for alerts."
|
||||
},
|
||||
"detections": {
|
||||
"label": "Enable GenAI for detections."
|
||||
},
|
||||
"additional_concerns": {
|
||||
"label": "Additional concerns that GenAI should make note of on this camera."
|
||||
},
|
||||
"debug_save_thumbnails": {
|
||||
"label": "Save thumbnails sent to generative AI for debugging purposes."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "Keep track of original state of generative AI."
|
||||
},
|
||||
"preferred_language": {
|
||||
"label": "Preferred language for GenAI Response"
|
||||
},
|
||||
"activity_context_prompt": {
|
||||
"label": "Custom activity context prompt defining normal activity patterns for this property."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"label": "If Frigate should be started in safe mode."
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"label": "Semantic search configuration.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable semantic search."
|
||||
},
|
||||
"reindex": {
|
||||
"label": "Reindex all tracked objects on startup."
|
||||
},
|
||||
"model": {
|
||||
"label": "The CLIP model to use for semantic search."
|
||||
},
|
||||
"model_size": {
|
||||
"label": "The size of the embeddings model used."
|
||||
},
|
||||
"device": {
|
||||
"label": "The device key to use for semantic search.",
|
||||
"description": "This is an override, to target a specific device. See https://onnxruntime.ai/docs/execution-providers/ for more information"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"label": "Global snapshots configuration.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Snapshots enabled."
|
||||
},
|
||||
"clean_copy": {
|
||||
"label": "Create a clean copy of the snapshot image."
|
||||
},
|
||||
"timestamp": {
|
||||
"label": "Add a timestamp overlay on the snapshot."
|
||||
},
|
||||
"bounding_box": {
|
||||
"label": "Add a bounding box overlay on the snapshot."
|
||||
},
|
||||
"crop": {
|
||||
"label": "Crop the snapshot to the detected object."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "List of required zones to be entered in order to save a snapshot."
|
||||
},
|
||||
"height": {
|
||||
"label": "Snapshot image height."
|
||||
},
|
||||
"retain": {
|
||||
"label": "Snapshot retention.",
|
||||
"properties": {
|
||||
"default": {
|
||||
"label": "Default retention period."
|
||||
},
|
||||
"mode": {
|
||||
"label": "Retain mode."
|
||||
},
|
||||
"objects": {
|
||||
"label": "Object retention period."
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality": {
|
||||
"label": "Quality of the encoded jpeg (0-100)."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"label": "Telemetry configuration.",
|
||||
"properties": {
|
||||
"network_interfaces": {
|
||||
"label": "Enabled network interfaces for bandwidth calculation."
|
||||
},
|
||||
"stats": {
|
||||
"label": "System Stats Configuration",
|
||||
"properties": {
|
||||
"amd_gpu_stats": {
|
||||
"label": "Enable AMD GPU stats."
|
||||
},
|
||||
"intel_gpu_stats": {
|
||||
"label": "Enable Intel GPU stats."
|
||||
},
|
||||
"network_bandwidth": {
|
||||
"label": "Enable network bandwidth for ffmpeg processes."
|
||||
},
|
||||
"intel_gpu_device": {
|
||||
"label": "Define the device to use when gathering SR-IOV stats."
|
||||
}
|
||||
}
|
||||
},
|
||||
"version_check": {
|
||||
"label": "Enable latest version check."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"label": "Global timestamp style configuration.",
|
||||
"properties": {
|
||||
"position": {
|
||||
"label": "Timestamp position."
|
||||
},
|
||||
"format": {
|
||||
"label": "Timestamp format."
|
||||
},
|
||||
"color": {
|
||||
"label": "Timestamp color.",
|
||||
"properties": {
|
||||
"red": {
|
||||
"label": "Red"
|
||||
},
|
||||
"green": {
|
||||
"label": "Green"
|
||||
},
|
||||
"blue": {
|
||||
"label": "Blue"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thickness": {
|
||||
"label": "Timestamp thickness."
|
||||
},
|
||||
"effect": {
|
||||
"label": "Timestamp effect."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"label": "TLS configuration.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"label": "Enable TLS for port 8971"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"label": "UI configuration.",
|
||||
"properties": {
|
||||
"timezone": {
|
||||
"label": "Override UI timezone."
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Override UI time format."
|
||||
},
|
||||
"date_style": {
|
||||
"label": "Override UI dateStyle."
|
||||
},
|
||||
"time_style": {
|
||||
"label": "Override UI timeStyle."
|
||||
},
|
||||
"unit_system": {
|
||||
"label": "The unit system to use for measurements."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"minimum": "Must be at least {{limit}}",
|
||||
"maximum": "Must be at most {{limit}}",
|
||||
"exclusiveMinimum": "Must be greater than {{limit}}",
|
||||
"exclusiveMaximum": "Must be less than {{limit}}",
|
||||
"minLength": "Must be at least {{limit}} character(s)",
|
||||
"maxLength": "Must be at most {{limit}} character(s)",
|
||||
"minItems": "Must have at least {{limit}} items",
|
||||
"maxItems": "Must have at most {{limit}} items",
|
||||
"pattern": "Invalid format",
|
||||
"required": "This field is required",
|
||||
"type": "Invalid value type",
|
||||
"enum": "Must be one of the allowed values",
|
||||
"const": "Value does not match expected constant",
|
||||
"uniqueItems": "All items must be unique",
|
||||
"format": "Invalid format",
|
||||
"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",
|
||||
"proxy": {
|
||||
"header_map": {
|
||||
"roleHeaderRequired": "Role header is required when role mappings are configured."
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"inputs": {
|
||||
"rolesUnique": "Each role can only be assigned to one input stream.",
|
||||
"detectRequired": "At least one input stream must be assigned the 'detect' role.",
|
||||
"hwaccelDetectOnly": "Only the input stream with the detect role can define hardware acceleration arguments."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"label": "Current config version."
|
||||
}
|
||||
@@ -8,23 +8,82 @@
|
||||
"masksAndZones": "Mask and Zone Editor - Frigate",
|
||||
"motionTuner": "Motion Tuner - Frigate",
|
||||
"object": "Debug - Frigate",
|
||||
"general": "UI Settings - Frigate",
|
||||
"general": "Profile 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": {
|
||||
"general": "General",
|
||||
"globalConfig": "Global configuration",
|
||||
"system": "System",
|
||||
"integrations": "Integrations",
|
||||
"cameras": "Camera configuration",
|
||||
"ui": "UI",
|
||||
"enrichments": "Enrichments",
|
||||
"profileSettings": "Profile settings",
|
||||
"globalDetect": "Object detection",
|
||||
"globalRecording": "Recording",
|
||||
"globalSnapshots": "Snapshots",
|
||||
"globalFfmpeg": "FFmpeg",
|
||||
"globalMotion": "Motion detection",
|
||||
"globalObjects": "Objects",
|
||||
"globalReview": "Review",
|
||||
"globalAudioEvents": "Audio events",
|
||||
"globalLivePlayback": "Live playback",
|
||||
"globalTimestampStyle": "Timestamp style",
|
||||
"systemDatabase": "Database",
|
||||
"systemTls": "TLS",
|
||||
"systemAuthentication": "Authentication",
|
||||
"systemNetworking": "Networking",
|
||||
"systemProxy": "Proxy",
|
||||
"systemUi": "UI",
|
||||
"systemLogging": "Logging",
|
||||
"systemEnvironmentVariables": "Environment variables",
|
||||
"systemTelemetry": "Telemetry",
|
||||
"systemBirdseye": "Birdseye",
|
||||
"systemFfmpeg": "FFmpeg",
|
||||
"systemDetectorHardware": "Detector hardware",
|
||||
"systemDetectionModel": "Detection model",
|
||||
"systemMqtt": "MQTT",
|
||||
"integrationSemanticSearch": "Semantic search",
|
||||
"integrationGenerativeAi": "Generative AI",
|
||||
"integrationFaceRecognition": "Face recognition",
|
||||
"integrationLpr": "License plate recognition",
|
||||
"integrationObjectClassification": "Object classification",
|
||||
"integrationAudioTranscription": "Audio transcription",
|
||||
"cameraDetect": "Object detection",
|
||||
"cameraFfmpeg": "FFmpeg",
|
||||
"cameraRecording": "Recording",
|
||||
"cameraSnapshots": "Snapshots",
|
||||
"cameraMotion": "Motion detection",
|
||||
"cameraObjects": "Objects",
|
||||
"cameraConfigReview": "Review",
|
||||
"cameraAudioEvents": "Audio events",
|
||||
"cameraAudioTranscription": "Audio transcription",
|
||||
"cameraNotifications": "Notifications",
|
||||
"cameraLivePlayback": "Live playback",
|
||||
"cameraBirdseye": "Birdseye",
|
||||
"cameraFaceRecognition": "Face recognition",
|
||||
"cameraLpr": "License plate recognition",
|
||||
"cameraMqttConfig": "MQTT",
|
||||
"cameraOnvif": "ONVIF",
|
||||
"cameraUi": "Camera UI",
|
||||
"cameraTimestampStyle": "Timestamp style",
|
||||
"cameraMqtt": "Camera MQTT",
|
||||
"cameraManagement": "Management",
|
||||
"cameraReview": "Review",
|
||||
"masksAndZones": "Masks / Zones",
|
||||
"motionTuner": "Motion Tuner",
|
||||
"triggers": "Triggers",
|
||||
"debug": "Debug",
|
||||
"motionTuner": "Motion tuner",
|
||||
"enrichments": "Enrichments",
|
||||
"users": "Users",
|
||||
"roles": "Roles",
|
||||
"notifications": "Notifications",
|
||||
"frigateplus": "Frigate+"
|
||||
"triggers": "Triggers",
|
||||
"debug": "Debug",
|
||||
"frigateplus": "Frigate+",
|
||||
"maintenance": "Maintenance"
|
||||
},
|
||||
"dialog": {
|
||||
"unsavedChanges": {
|
||||
@@ -32,12 +91,29 @@
|
||||
"desc": "Do you want to save your changes before continuing?"
|
||||
}
|
||||
},
|
||||
"saveAllPreview": {
|
||||
"title": "Changes to be saved",
|
||||
"triggerLabel": "Review pending changes",
|
||||
"empty": "No pending changes.",
|
||||
"scope": {
|
||||
"label": "Scope",
|
||||
"global": "Global",
|
||||
"camera": "Camera: {{cameraName}}"
|
||||
},
|
||||
"field": {
|
||||
"label": "Field"
|
||||
},
|
||||
"value": {
|
||||
"label": "New value",
|
||||
"reset": "Reset"
|
||||
}
|
||||
},
|
||||
"cameraSetting": {
|
||||
"camera": "Camera",
|
||||
"noCamera": "No Camera"
|
||||
},
|
||||
"general": {
|
||||
"title": "UI Settings",
|
||||
"title": "Profile Settings",
|
||||
"liveDashboard": {
|
||||
"title": "Live Dashboard",
|
||||
"automaticLiveView": {
|
||||
@@ -106,7 +182,7 @@
|
||||
"desc": "Semantic Search in Frigate allows you to find tracked objects within your review items using either the image itself, a user-defined text description, or an automatically generated one.",
|
||||
"reindexNow": {
|
||||
"label": "Reindex Now",
|
||||
"desc": "Reindexing will regenerate embeddings for all tracked object. This process runs in the background and may max out your CPU and take a fair amount of time depending on the number of tracked objects you have.",
|
||||
"desc": "Reindexing will regenerate embeddings for all tracked objects. This process runs in the background and may max out your CPU and take a fair amount of time depending on the number of tracked objects you have.",
|
||||
"confirmTitle": "Confirm Reindexing",
|
||||
"confirmDesc": "Are you sure you want to reindex all tracked object embeddings? This process will run in the background but it may max out your CPU and take a fair amount of time. You can watch the progress on the Explore page.",
|
||||
"confirmButton": "Reindex",
|
||||
@@ -350,7 +426,11 @@
|
||||
"backToSettings": "Back to Camera Settings",
|
||||
"streams": {
|
||||
"title": "Enable / Disable Cameras",
|
||||
"desc": "Temporarily disable a camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em>"
|
||||
"enableLabel": "Enabled cameras",
|
||||
"enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em>",
|
||||
"disableLabel": "Disabled cameras",
|
||||
"disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.",
|
||||
"enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes."
|
||||
},
|
||||
"cameraConfig": {
|
||||
"add": "Add Camera",
|
||||
@@ -906,6 +986,13 @@
|
||||
},
|
||||
"frigatePlus": {
|
||||
"title": "Frigate+ Settings",
|
||||
"description": "Frigate+ is a subscription service that provides access to additional features and capabilities for your Frigate instance, including the ability to use custom object detection models trained on your own data. You can manage your Frigate+ model settings here.",
|
||||
"cardTitles": {
|
||||
"api": "API",
|
||||
"currentModel": "Current Model",
|
||||
"otherModels": "Other Models",
|
||||
"configuration": "Configuration"
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "Frigate+ API Key",
|
||||
"validated": "Frigate+ API key is detected and validated",
|
||||
@@ -947,6 +1034,15 @@
|
||||
"error": "Failed to save config changes: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"detectionModel": {
|
||||
"plusActive": {
|
||||
"title": "Frigate+ model management",
|
||||
"label": "Current model source",
|
||||
"description": "This instance is running a Frigate+ model. Select or change your model in Frigate+ settings.",
|
||||
"goToFrigatePlus": "Go to Frigate+ settings",
|
||||
"showModelForm": "Manually configure a model"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"documentTitle": "Triggers",
|
||||
"semanticSearch": {
|
||||
@@ -1115,5 +1211,178 @@
|
||||
"exports": "Exports",
|
||||
"recordings": "Recordings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"configForm": {
|
||||
"global": {
|
||||
"title": "Global Settings",
|
||||
"description": "These settings apply to all cameras unless overridden in the camera-specific settings."
|
||||
},
|
||||
"camera": {
|
||||
"title": "Camera Settings",
|
||||
"description": "These settings apply only to this camera and override the global settings."
|
||||
},
|
||||
"advancedSettingsCount": "Advanced Settings ({{count}})",
|
||||
"advancedCount": "Advanced ({{count}})",
|
||||
"showAdvanced": "Show Advanced Settings",
|
||||
"tabs": {
|
||||
"sharedDefaults": "Shared Defaults",
|
||||
"system": "System",
|
||||
"integrations": "Integrations"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"keyLabel": "Key",
|
||||
"valueLabel": "Value",
|
||||
"keyPlaceholder": "New key",
|
||||
"remove": "Remove"
|
||||
},
|
||||
"timezone": {
|
||||
"defaultOption": "Use browser timezone"
|
||||
},
|
||||
"roleMap": {
|
||||
"empty": "No role mappings",
|
||||
"roleLabel": "Role",
|
||||
"groupsLabel": "Groups",
|
||||
"addMapping": "Add role mapping",
|
||||
"remove": "Remove"
|
||||
},
|
||||
"ffmpegArgs": {
|
||||
"preset": "Preset",
|
||||
"manual": "Manual arguments",
|
||||
"inherit": "Inherit from camera setting",
|
||||
"selectPreset": "Select preset",
|
||||
"manualPlaceholder": "Enter FFmpeg arguments"
|
||||
},
|
||||
"cameraInputs": {
|
||||
"itemTitle": "Stream {{index}}"
|
||||
},
|
||||
"restartRequiredField": "Restart required",
|
||||
"restartRequiredFooter": "Configuration changed - Restart required",
|
||||
"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"
|
||||
},
|
||||
"detectors": {
|
||||
"title": "Detector Settings",
|
||||
"singleType": "Only one {{type}} detector is allowed.",
|
||||
"keyRequired": "Detector name is required.",
|
||||
"keyDuplicate": "Detector name already exists.",
|
||||
"noSchema": "No detector schemas are available.",
|
||||
"none": "No detector instances configured.",
|
||||
"add": "Add detector"
|
||||
},
|
||||
"record": {
|
||||
"title": "Recording Settings"
|
||||
},
|
||||
"snapshots": {
|
||||
"title": "Snapshot Settings"
|
||||
},
|
||||
"motion": {
|
||||
"title": "Motion Settings"
|
||||
},
|
||||
"objects": {
|
||||
"title": "Object Settings"
|
||||
},
|
||||
"audioLabels": {
|
||||
"summary": "{{count}} audio labels selected",
|
||||
"empty": "No audio labels available"
|
||||
},
|
||||
"objectLabels": {
|
||||
"summary": "{{count}} object types selected",
|
||||
"empty": "No object labels available"
|
||||
},
|
||||
"filters": {
|
||||
"objectFieldLabel": "{{field}} for {{label}}"
|
||||
},
|
||||
"zoneNames": {
|
||||
"summary": "{{count}} selected",
|
||||
"empty": "No zones available"
|
||||
},
|
||||
"inputRoles": {
|
||||
"summary": "{{count}} roles selected",
|
||||
"empty": "No roles available",
|
||||
"options": {
|
||||
"detect": "Detect",
|
||||
"record": "Record",
|
||||
"audio": "Audio"
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"title": "Review Settings"
|
||||
},
|
||||
"audio": {
|
||||
"title": "Audio Settings"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notification Settings"
|
||||
},
|
||||
"live": {
|
||||
"title": "Live View Settings"
|
||||
},
|
||||
"timestamp_style": {
|
||||
"title": "Timestamp Settings"
|
||||
},
|
||||
"searchPlaceholder": "Search..."
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"success": "Settings saved successfully",
|
||||
"successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.",
|
||||
"error": "Failed to save settings",
|
||||
"validationError": "Validation failed: {{message}}",
|
||||
"resetSuccess": "Reset to global defaults",
|
||||
"resetError": "Failed to reset settings",
|
||||
"saveAllSuccess_one": "Saved {{count}} section successfully.",
|
||||
"saveAllSuccess_other": "All {{count}} sections saved successfully.",
|
||||
"saveAllPartial_one": "{{successCount}} of {{totalCount}} section saved. {{failCount}} failed.",
|
||||
"saveAllPartial_other": "{{successCount}} of {{totalCount}} sections saved. {{failCount}} failed.",
|
||||
"saveAllFailure": "Failed to save all sections."
|
||||
},
|
||||
"unsavedChanges": "You have unsaved changes",
|
||||
"confirmReset": "Confirm Reset",
|
||||
"resetToDefaultDescription": "This will reset all settings in this section to their default values. This action cannot be undone.",
|
||||
"resetToGlobalDescription": "This will reset the settings in this section to the global defaults. This action cannot be undone."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Label } from "../ui/label";
|
||||
|
||||
export const SPLIT_ROW_CLASS_NAME =
|
||||
"space-y-2 md:grid md:grid-cols-[minmax(14rem,24rem)_minmax(0,1fr)] md:items-start md:gap-x-6 md:space-y-0";
|
||||
export const DESCRIPTION_CLASS_NAME = "text-sm text-muted-foreground";
|
||||
export const CONTROL_COLUMN_CLASS_NAME = "w-full md:max-w-2xl";
|
||||
|
||||
type SettingsGroupCardProps = {
|
||||
title: string | ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function SettingsGroupCard({ title, children }: SettingsGroupCardProps) {
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4">
|
||||
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SplitCardRowProps = {
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
export function SplitCardRow({
|
||||
label,
|
||||
description,
|
||||
content,
|
||||
}: SplitCardRowProps) {
|
||||
return (
|
||||
<div className={SPLIT_ROW_CLASS_NAME}>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
{description && (
|
||||
<div className={`hidden md:block ${DESCRIPTION_CLASS_NAME}`}>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}>
|
||||
{content}
|
||||
{description && (
|
||||
<div className={`md:hidden ${DESCRIPTION_CLASS_NAME}`}>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// ConfigForm - Main RJSF form wrapper component
|
||||
import Form from "@rjsf/shadcn";
|
||||
import validator from "@rjsf/validator-ajv8";
|
||||
import type { FormValidation, 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 } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn, mergeUiSchema } from "@/lib/utils";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
|
||||
type SchemaWithProperties = RJSFSchema & {
|
||||
properties: Record<string, RJSFSchema>;
|
||||
};
|
||||
|
||||
type SchemaWithAdditionalProperties = RJSFSchema & {
|
||||
additionalProperties: RJSFSchema;
|
||||
};
|
||||
|
||||
// Runtime guards for schema fragments
|
||||
const hasSchemaProperties = (
|
||||
schema: RJSFSchema,
|
||||
): schema is SchemaWithProperties =>
|
||||
typeof schema === "object" &&
|
||||
schema !== null &&
|
||||
typeof schema.properties === "object" &&
|
||||
schema.properties !== null;
|
||||
|
||||
const hasSchemaAdditionalProperties = (
|
||||
schema: RJSFSchema,
|
||||
): schema is SchemaWithAdditionalProperties =>
|
||||
typeof schema === "object" &&
|
||||
schema !== null &&
|
||||
typeof schema.additionalProperties === "object" &&
|
||||
schema.additionalProperties !== null;
|
||||
|
||||
// Detects path-style uiSchema keys (e.g., "filters.*.mask")
|
||||
const isPathKey = (key: string) => key.includes(".") || key.includes("*");
|
||||
|
||||
type UiSchemaPathOverride = {
|
||||
path: string[];
|
||||
value: UiSchema;
|
||||
};
|
||||
|
||||
// Split uiSchema into normal keys vs path-based overrides
|
||||
const splitUiSchemaOverrides = (
|
||||
uiSchema?: UiSchema,
|
||||
): { baseUiSchema?: UiSchema; pathOverrides: UiSchemaPathOverride[] } => {
|
||||
if (!uiSchema) {
|
||||
return { baseUiSchema: undefined, pathOverrides: [] };
|
||||
}
|
||||
|
||||
const baseUiSchema: UiSchema = {};
|
||||
const pathOverrides: UiSchemaPathOverride[] = [];
|
||||
|
||||
Object.entries(uiSchema).forEach(([key, value]) => {
|
||||
if (isPathKey(key)) {
|
||||
pathOverrides.push({
|
||||
path: key.split("."),
|
||||
value: value as UiSchema,
|
||||
});
|
||||
} else {
|
||||
baseUiSchema[key] = value as UiSchema;
|
||||
}
|
||||
});
|
||||
|
||||
return { baseUiSchema, pathOverrides };
|
||||
};
|
||||
|
||||
// Apply wildcard path overrides to uiSchema using the schema structure
|
||||
const applyUiSchemaPathOverrides = (
|
||||
uiSchema: UiSchema,
|
||||
schema: RJSFSchema,
|
||||
overrides: UiSchemaPathOverride[],
|
||||
): UiSchema => {
|
||||
if (overrides.length === 0) {
|
||||
return uiSchema;
|
||||
}
|
||||
|
||||
// Recursively apply a path override; supports "*" to match any property.
|
||||
const applyOverride = (
|
||||
targetUi: UiSchema,
|
||||
targetSchema: RJSFSchema,
|
||||
path: string[],
|
||||
value: UiSchema,
|
||||
) => {
|
||||
if (path.length === 0) {
|
||||
Object.assign(targetUi, mergeUiSchema(targetUi, value));
|
||||
return;
|
||||
}
|
||||
|
||||
const [segment, ...rest] = path;
|
||||
const schemaObj = targetSchema;
|
||||
|
||||
if (segment === "*") {
|
||||
if (hasSchemaProperties(schemaObj)) {
|
||||
Object.entries(schemaObj.properties).forEach(
|
||||
([propertyName, propertySchema]) => {
|
||||
const existing =
|
||||
(targetUi[propertyName] as UiSchema | undefined) || {};
|
||||
targetUi[propertyName] = { ...existing };
|
||||
applyOverride(
|
||||
targetUi[propertyName] as UiSchema,
|
||||
propertySchema,
|
||||
rest,
|
||||
value,
|
||||
);
|
||||
},
|
||||
);
|
||||
} else if (hasSchemaAdditionalProperties(schemaObj)) {
|
||||
// For dict schemas, apply override to additionalProperties
|
||||
const existing =
|
||||
(targetUi.additionalProperties as UiSchema | undefined) || {};
|
||||
targetUi.additionalProperties = { ...existing };
|
||||
applyOverride(
|
||||
targetUi.additionalProperties as UiSchema,
|
||||
schemaObj.additionalProperties,
|
||||
rest,
|
||||
value,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasSchemaProperties(schemaObj)) {
|
||||
const propertySchema = schemaObj.properties[segment];
|
||||
if (propertySchema) {
|
||||
const existing = (targetUi[segment] as UiSchema | undefined) || {};
|
||||
targetUi[segment] = { ...existing };
|
||||
applyOverride(
|
||||
targetUi[segment] as UiSchema,
|
||||
propertySchema,
|
||||
rest,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updated = { ...uiSchema };
|
||||
overrides.forEach(({ path, value }) => {
|
||||
applyOverride(updated, schema, path, value);
|
||||
});
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
const applyLayoutGridFieldDefaults = (uiSchema: UiSchema): UiSchema => {
|
||||
const applyDefaults = (node: unknown): unknown => {
|
||||
if (Array.isArray(node)) {
|
||||
return node.map((item) => applyDefaults(item));
|
||||
}
|
||||
|
||||
if (typeof node !== "object" || node === null) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const nextNode: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(node).forEach(([key, value]) => {
|
||||
nextNode[key] = applyDefaults(value);
|
||||
});
|
||||
|
||||
if (
|
||||
Array.isArray(nextNode["ui:layoutGrid"]) &&
|
||||
nextNode["ui:field"] === undefined
|
||||
) {
|
||||
nextNode["ui:field"] = "LayoutGridField";
|
||||
}
|
||||
|
||||
return nextNode;
|
||||
};
|
||||
|
||||
return applyDefaults(uiSchema) as UiSchema;
|
||||
};
|
||||
|
||||
export interface ConfigFormProps {
|
||||
/** JSON Schema for the form */
|
||||
schema: RJSFSchema;
|
||||
/** Current form data */
|
||||
formData?: unknown;
|
||||
/** Called when form data changes */
|
||||
onChange?: (data: unknown) => void;
|
||||
/** Called when form is submitted */
|
||||
onSubmit?: (data: unknown) => void;
|
||||
/** Called when form has errors on submit */
|
||||
onError?: (errors: unknown[]) => void;
|
||||
/** Additional uiSchema overrides */
|
||||
uiSchema?: UiSchema;
|
||||
/** Field ordering */
|
||||
fieldOrder?: string[];
|
||||
/** Field groups for layout */
|
||||
fieldGroups?: Record<string, 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?: ConfigFormContext;
|
||||
/** i18n namespace for field labels */
|
||||
i18nNamespace?: string;
|
||||
/** Optional custom validation */
|
||||
customValidate?: (
|
||||
formData: unknown,
|
||||
errors: FormValidation,
|
||||
) => FormValidation;
|
||||
/** Called whenever form validation state changes */
|
||||
onValidationChange?: (hasErrors: boolean) => void;
|
||||
}
|
||||
|
||||
export function ConfigForm({
|
||||
schema,
|
||||
formData,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onError,
|
||||
uiSchema: customUiSchema,
|
||||
fieldOrder,
|
||||
fieldGroups,
|
||||
hiddenFields,
|
||||
advancedFields,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
showSubmit = false,
|
||||
className,
|
||||
liveValidate = true,
|
||||
formContext,
|
||||
i18nNamespace,
|
||||
customValidate,
|
||||
onValidationChange,
|
||||
}: ConfigFormProps) {
|
||||
const { t, i18n } = useTranslation([
|
||||
i18nNamespace || "common",
|
||||
"views/settings",
|
||||
"config/validation",
|
||||
]);
|
||||
|
||||
// Determine which fields to hide based on advanced toggle
|
||||
const effectiveHiddenFields = useMemo(() => {
|
||||
return hiddenFields;
|
||||
}, [hiddenFields]);
|
||||
|
||||
// Transform schema and generate uiSchema
|
||||
const { schema: transformedSchema, uiSchema: generatedUiSchema } = useMemo(
|
||||
() =>
|
||||
transformSchema(schema, {
|
||||
fieldOrder,
|
||||
hiddenFields: effectiveHiddenFields,
|
||||
advancedFields: advancedFields,
|
||||
i18nNamespace,
|
||||
}),
|
||||
[schema, fieldOrder, effectiveHiddenFields, advancedFields, i18nNamespace],
|
||||
);
|
||||
|
||||
const { baseUiSchema, pathOverrides } = useMemo(
|
||||
() => splitUiSchemaOverrides(customUiSchema),
|
||||
[customUiSchema],
|
||||
);
|
||||
|
||||
// Merge generated uiSchema with custom overrides
|
||||
const finalUiSchema = useMemo(() => {
|
||||
// Start with generated schema
|
||||
const expandedUiSchema = applyUiSchemaPathOverrides(
|
||||
generatedUiSchema,
|
||||
transformedSchema,
|
||||
pathOverrides,
|
||||
);
|
||||
const merged = applyLayoutGridFieldDefaults(
|
||||
mergeUiSchema(expandedUiSchema, baseUiSchema),
|
||||
);
|
||||
|
||||
// Add field groups
|
||||
if (fieldGroups) {
|
||||
merged["ui:groups"] = fieldGroups;
|
||||
}
|
||||
|
||||
// Set submit button options
|
||||
merged["ui:submitButtonOptions"] = showSubmit
|
||||
? { norender: false }
|
||||
: { norender: true };
|
||||
|
||||
// Ensure hiddenFields take precedence over any custom uiSchema overrides
|
||||
// Build path-based overrides for hidden fields and apply them after merging
|
||||
if (hiddenFields && hiddenFields.length > 0) {
|
||||
const hiddenOverrides = hiddenFields.map((field) => ({
|
||||
path: field.split("."),
|
||||
value: { "ui:widget": "hidden" } as UiSchema,
|
||||
}));
|
||||
|
||||
return applyUiSchemaPathOverrides(
|
||||
merged,
|
||||
transformedSchema,
|
||||
hiddenOverrides,
|
||||
);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, [
|
||||
generatedUiSchema,
|
||||
transformedSchema,
|
||||
pathOverrides,
|
||||
baseUiSchema,
|
||||
showSubmit,
|
||||
fieldGroups,
|
||||
hiddenFields,
|
||||
]);
|
||||
|
||||
// Create error transformer for user-friendly error messages
|
||||
const errorTransformer = useMemo(() => createErrorTransformer(i18n), [i18n]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: IChangeEvent) => {
|
||||
onValidationChange?.(Array.isArray(e.errors) && e.errors.length > 0);
|
||||
onChange?.(e.formData);
|
||||
},
|
||||
[onChange, onValidationChange],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: IChangeEvent) => {
|
||||
onSubmit?.(e.formData);
|
||||
},
|
||||
[onSubmit],
|
||||
);
|
||||
|
||||
// Extended form context with i18n info
|
||||
const extendedFormContext = useMemo(
|
||||
() => ({
|
||||
...formContext,
|
||||
i18nNamespace,
|
||||
t,
|
||||
}),
|
||||
[formContext, i18nNamespace, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("config-form w-full max-w-5xl", className)}>
|
||||
<Form
|
||||
schema={transformedSchema}
|
||||
uiSchema={finalUiSchema}
|
||||
formData={formData}
|
||||
validator={validator}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
onError={onError}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
liveValidate={liveValidate}
|
||||
formContext={extendedFormContext}
|
||||
transformErrors={errorTransformer}
|
||||
customValidate={customValidate}
|
||||
{...frigateTheme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigForm;
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const audio: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/audio_detectors",
|
||||
restartRequired: [],
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"listen",
|
||||
"filters",
|
||||
"min_volume",
|
||||
"max_not_heard",
|
||||
"num_threads",
|
||||
],
|
||||
fieldGroups: {
|
||||
detection: ["enabled", "listen", "filters"],
|
||||
sensitivity: ["min_volume", "max_not_heard"],
|
||||
},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["min_volume", "max_not_heard", "num_threads"],
|
||||
uiSchema: {
|
||||
listen: {
|
||||
"ui:widget": "audioLabels",
|
||||
},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"listen",
|
||||
"filters",
|
||||
"min_volume",
|
||||
"max_not_heard",
|
||||
"num_threads",
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["num_threads"],
|
||||
},
|
||||
};
|
||||
|
||||
export default audio;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const audioTranscription: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/audio_detectors#audio-transcription",
|
||||
restartRequired: [],
|
||||
fieldOrder: ["enabled", "language", "device", "model_size"],
|
||||
hiddenFields: ["enabled_in_config", "live_enabled"],
|
||||
advancedFields: ["language", "device", "model_size"],
|
||||
overrideFields: ["enabled", "live_enabled"],
|
||||
},
|
||||
global: {
|
||||
fieldOrder: ["enabled", "language", "device", "model_size"],
|
||||
advancedFields: ["language", "device", "model_size"],
|
||||
restartRequired: ["enabled", "language", "device", "model_size"],
|
||||
},
|
||||
};
|
||||
|
||||
export default audioTranscription;
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const auth: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/authentication",
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"reset_admin_password",
|
||||
"failed_login_rate_limit",
|
||||
],
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"reset_admin_password",
|
||||
"cookie_name",
|
||||
"cookie_secure",
|
||||
"session_length",
|
||||
"refresh_time",
|
||||
"native_oauth_url",
|
||||
"failed_login_rate_limit",
|
||||
"trusted_proxies",
|
||||
"hash_iterations",
|
||||
"roles",
|
||||
],
|
||||
hiddenFields: ["admin_first_time_login", "roles"],
|
||||
advancedFields: [
|
||||
"cookie_name",
|
||||
"cookie_secure",
|
||||
"session_length",
|
||||
"refresh_time",
|
||||
"failed_login_rate_limit",
|
||||
"trusted_proxies",
|
||||
"hash_iterations",
|
||||
"roles",
|
||||
],
|
||||
uiSchema: {
|
||||
reset_admin_password: {
|
||||
"ui:widget": "switch",
|
||||
},
|
||||
native_oauth_url: {
|
||||
"ui:options": { size: "lg" },
|
||||
},
|
||||
failed_login_rate_limit: {
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default auth;
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const birdseye: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/birdseye",
|
||||
restartRequired: [],
|
||||
fieldOrder: ["enabled", "mode", "order"],
|
||||
hiddenFields: [],
|
||||
advancedFields: [],
|
||||
overrideFields: ["enabled", "mode"],
|
||||
},
|
||||
global: {
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"restream",
|
||||
"width",
|
||||
"height",
|
||||
"quality",
|
||||
"mode",
|
||||
"layout",
|
||||
"inactivity_threshold",
|
||||
"idle_heartbeat_fps",
|
||||
],
|
||||
advancedFields: ["width", "height", "quality", "inactivity_threshold"],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"restream",
|
||||
"width",
|
||||
"height",
|
||||
"quality",
|
||||
"mode",
|
||||
"layout.scaling_factor",
|
||||
"inactivity_threshold",
|
||||
"layout.max_cameras",
|
||||
"idle_heartbeat_fps",
|
||||
],
|
||||
uiSchema: {
|
||||
mode: {
|
||||
"ui:size": "xs",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default birdseye;
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const classification: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/custom_classification/object_classification",
|
||||
restartRequired: ["bird.enabled", "bird.threshold"],
|
||||
hiddenFields: ["custom"],
|
||||
advancedFields: [],
|
||||
},
|
||||
};
|
||||
|
||||
export default classification;
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const database: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/advanced#database",
|
||||
restartRequired: ["path"],
|
||||
fieldOrder: ["path"],
|
||||
advancedFields: [],
|
||||
uiSchema: {
|
||||
path: {
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default database;
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const detect: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/camera_specific",
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"width",
|
||||
"height",
|
||||
"fps",
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
"annotation_offset",
|
||||
"stationary",
|
||||
"interval",
|
||||
"threshold",
|
||||
"max_frames",
|
||||
],
|
||||
restartRequired: [],
|
||||
fieldGroups: {
|
||||
resolution: ["enabled", "width", "height", "fps"],
|
||||
tracking: ["min_initialized", "max_disappeared"],
|
||||
},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: [
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
"annotation_offset",
|
||||
"stationary",
|
||||
],
|
||||
},
|
||||
global: {
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"width",
|
||||
"height",
|
||||
"fps",
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
"annotation_offset",
|
||||
"stationary",
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
||||
},
|
||||
};
|
||||
|
||||
export default detect;
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const detectorHiddenFields = [
|
||||
"*.model.labelmap",
|
||||
"*.model.attributes_map",
|
||||
"*.model",
|
||||
"*.model_path",
|
||||
];
|
||||
|
||||
const detectors: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/object_detectors",
|
||||
fieldOrder: [],
|
||||
advancedFields: [],
|
||||
hiddenFields: detectorHiddenFields,
|
||||
uiSchema: {
|
||||
"ui:field": "DetectorHardwareField",
|
||||
"ui:options": {
|
||||
multiInstanceTypes: ["cpu", "onnx", "openvino"],
|
||||
typeOrder: ["onnx", "openvino", "edgetpu"],
|
||||
hiddenByType: {},
|
||||
hiddenFields: detectorHiddenFields,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default detectors;
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const environmentVars: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/advanced#environment_vars",
|
||||
fieldOrder: [],
|
||||
advancedFields: [],
|
||||
uiSchema: {
|
||||
additionalProperties: {
|
||||
"ui:options": { size: "lg" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default environmentVars;
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const faceRecognition: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/face_recognition",
|
||||
restartRequired: [],
|
||||
fieldOrder: ["enabled", "min_area"],
|
||||
hiddenFields: [],
|
||||
advancedFields: ["min_area"],
|
||||
overrideFields: ["enabled", "min_area"],
|
||||
},
|
||||
global: {
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"model_size",
|
||||
"unknown_score",
|
||||
"detection_threshold",
|
||||
"recognition_threshold",
|
||||
"min_area",
|
||||
"min_faces",
|
||||
"save_attempts",
|
||||
"blur_confidence_filter",
|
||||
"device",
|
||||
],
|
||||
advancedFields: [
|
||||
"unknown_score",
|
||||
"detection_threshold",
|
||||
"recognition_threshold",
|
||||
"min_area",
|
||||
"min_faces",
|
||||
"save_attempts",
|
||||
"blur_confidence_filter",
|
||||
"device",
|
||||
],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"model_size",
|
||||
"unknown_score",
|
||||
"detection_threshold",
|
||||
"recognition_threshold",
|
||||
"min_area",
|
||||
"min_faces",
|
||||
"save_attempts",
|
||||
"blur_confidence_filter",
|
||||
"device",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default faceRecognition;
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const arrayAsTextWidget = {
|
||||
"ui:widget": "ArrayAsTextWidget",
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
},
|
||||
};
|
||||
|
||||
const ffmpegArgsWidget = (
|
||||
presetField: string,
|
||||
extraOptions?: Record<string, unknown>,
|
||||
) => ({
|
||||
"ui:widget": "FfmpegArgsWidget",
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
ffmpegPresetField: presetField,
|
||||
...extraOptions,
|
||||
},
|
||||
});
|
||||
|
||||
const ffmpeg: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/ffmpeg_presets",
|
||||
fieldDocs: {
|
||||
hwaccel_args: "/configuration/ffmpeg_presets#hwaccel-presets",
|
||||
"inputs.hwaccel_args": "/configuration/ffmpeg_presets#hwaccel-presets",
|
||||
input_args: "/configuration/ffmpeg_presets#input-args-presets",
|
||||
"inputs.input_args": "/configuration/ffmpeg_presets#input-args-presets",
|
||||
output_args: "/configuration/ffmpeg_presets#output-args-presets",
|
||||
"inputs.output_args": "/configuration/ffmpeg_presets#output-args-presets",
|
||||
"output_args.record": "/configuration/ffmpeg_presets#output-args-presets",
|
||||
"inputs.roles": "/configuration/cameras/#setting-up-camera-inputs",
|
||||
},
|
||||
restartRequired: [],
|
||||
fieldOrder: [
|
||||
"inputs",
|
||||
"global_args",
|
||||
"input_args",
|
||||
"hwaccel_args",
|
||||
"output_args",
|
||||
"path",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
hiddenFields: [],
|
||||
advancedFields: [
|
||||
"path",
|
||||
"global_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
overrideFields: [
|
||||
"inputs",
|
||||
"path",
|
||||
"global_args",
|
||||
"input_args",
|
||||
"hwaccel_args",
|
||||
"output_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
uiSchema: {
|
||||
path: {
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
global_args: arrayAsTextWidget,
|
||||
hwaccel_args: ffmpegArgsWidget("hwaccel_args"),
|
||||
input_args: ffmpegArgsWidget("input_args"),
|
||||
output_args: {
|
||||
detect: arrayAsTextWidget,
|
||||
record: ffmpegArgsWidget("output_args.record"),
|
||||
items: {
|
||||
detect: arrayAsTextWidget,
|
||||
record: ffmpegArgsWidget("output_args.record"),
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
"ui:field": "CameraInputsField",
|
||||
items: {
|
||||
path: {
|
||||
"ui:options": { size: "full" },
|
||||
},
|
||||
roles: {
|
||||
"ui:widget": "inputRoles",
|
||||
"ui:options": {
|
||||
showArrayItemDescription: true,
|
||||
},
|
||||
},
|
||||
global_args: {
|
||||
"ui:widget": "hidden",
|
||||
},
|
||||
hwaccel_args: ffmpegArgsWidget("hwaccel_args", {
|
||||
allowInherit: true,
|
||||
hideDescription: true,
|
||||
forceSplitLayout: true,
|
||||
showArrayItemDescription: true,
|
||||
}),
|
||||
input_args: ffmpegArgsWidget("input_args", {
|
||||
allowInherit: true,
|
||||
hideDescription: true,
|
||||
forceSplitLayout: true,
|
||||
showArrayItemDescription: true,
|
||||
}),
|
||||
output_args: {
|
||||
items: {
|
||||
detect: arrayAsTextWidget,
|
||||
record: ffmpegArgsWidget("output_args.record"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [
|
||||
"path",
|
||||
"global_args",
|
||||
"hwaccel_args",
|
||||
"input_args",
|
||||
"output_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
fieldOrder: [
|
||||
"hwaccel_args",
|
||||
"path",
|
||||
"global_args",
|
||||
"input_args",
|
||||
"output_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
advancedFields: [
|
||||
"global_args",
|
||||
"input_args",
|
||||
"output_args",
|
||||
"path",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
uiSchema: {
|
||||
path: {
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
global_args: arrayAsTextWidget,
|
||||
hwaccel_args: ffmpegArgsWidget("hwaccel_args"),
|
||||
input_args: ffmpegArgsWidget("input_args"),
|
||||
output_args: {
|
||||
detect: arrayAsTextWidget,
|
||||
record: ffmpegArgsWidget("output_args.record"),
|
||||
},
|
||||
},
|
||||
},
|
||||
camera: {
|
||||
fieldGroups: {
|
||||
cameraFfmpeg: ["input_args", "hwaccel_args", "output_args"],
|
||||
},
|
||||
restartRequired: [
|
||||
"inputs",
|
||||
"path",
|
||||
"global_args",
|
||||
"hwaccel_args",
|
||||
"input_args",
|
||||
"output_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default ffmpeg;
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const genai: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/genai/config",
|
||||
restartRequired: [
|
||||
"provider",
|
||||
"api_key",
|
||||
"base_url",
|
||||
"model",
|
||||
"provider_options",
|
||||
"runtime_options",
|
||||
],
|
||||
fieldOrder: [
|
||||
"provider",
|
||||
"api_key",
|
||||
"base_url",
|
||||
"model",
|
||||
"provider_options",
|
||||
"runtime_options",
|
||||
],
|
||||
advancedFields: ["base_url", "provider_options", "runtime_options"],
|
||||
hiddenFields: ["genai.enabled_in_config"],
|
||||
uiSchema: {
|
||||
api_key: {
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
base_url: {
|
||||
"ui:options": { size: "lg" },
|
||||
},
|
||||
model: {
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
provider_options: {
|
||||
additionalProperties: {
|
||||
"ui:options": { size: "lg" },
|
||||
},
|
||||
},
|
||||
runtime_options: {
|
||||
additionalProperties: {
|
||||
"ui:options": { size: "lg" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default genai;
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const live: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/live",
|
||||
restartRequired: [],
|
||||
fieldOrder: ["stream_name", "height", "quality"],
|
||||
fieldGroups: {},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["height", "quality"],
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["stream_name", "height", "quality"],
|
||||
hiddenFields: ["streams"],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["height", "quality"],
|
||||
},
|
||||
};
|
||||
|
||||
export default live;
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const logger: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/advanced#logger",
|
||||
restartRequired: ["default", "logs"],
|
||||
fieldOrder: ["default", "logs"],
|
||||
advancedFields: ["logs"],
|
||||
},
|
||||
};
|
||||
|
||||
export default logger;
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const lpr: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/license_plate_recognition",
|
||||
fieldDocs: {
|
||||
enhancement: "/configuration/license_plate_recognition#enhancement",
|
||||
},
|
||||
restartRequired: [],
|
||||
fieldOrder: ["enabled", "expire_time", "min_area", "enhancement"],
|
||||
hiddenFields: [],
|
||||
advancedFields: ["expire_time", "min_area", "enhancement"],
|
||||
overrideFields: ["enabled", "min_area", "enhancement"],
|
||||
},
|
||||
global: {
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"model_size",
|
||||
"detection_threshold",
|
||||
"min_area",
|
||||
"recognition_threshold",
|
||||
"min_plate_length",
|
||||
"format",
|
||||
"match_distance",
|
||||
"known_plates",
|
||||
"enhancement",
|
||||
"debug_save_plates",
|
||||
"device",
|
||||
"replace_rules",
|
||||
],
|
||||
advancedFields: [
|
||||
"detection_threshold",
|
||||
"recognition_threshold",
|
||||
"min_plate_length",
|
||||
"format",
|
||||
"match_distance",
|
||||
"known_plates",
|
||||
"enhancement",
|
||||
"debug_save_plates",
|
||||
"device",
|
||||
"replace_rules",
|
||||
],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"model_size",
|
||||
"detection_threshold",
|
||||
"min_area",
|
||||
"recognition_threshold",
|
||||
"min_plate_length",
|
||||
"format",
|
||||
"match_distance",
|
||||
"known_plates",
|
||||
"enhancement",
|
||||
"debug_save_plates",
|
||||
"device",
|
||||
"replace_rules",
|
||||
],
|
||||
uiSchema: {
|
||||
format: {
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
replace_rules: {
|
||||
"ui:field": "ReplaceRulesField",
|
||||
"ui:options": {
|
||||
label: false,
|
||||
suppressDescription: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default lpr;
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const model: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/object_detectors#model",
|
||||
restartRequired: [
|
||||
"path",
|
||||
"labelmap_path",
|
||||
"width",
|
||||
"height",
|
||||
"labelmap",
|
||||
"attributes_map",
|
||||
"input_tensor",
|
||||
"input_pixel_format",
|
||||
"input_dtype",
|
||||
"model_type",
|
||||
],
|
||||
fieldOrder: [
|
||||
"path",
|
||||
"labelmap_path",
|
||||
"width",
|
||||
"height",
|
||||
"input_pixel_format",
|
||||
"input_tensor",
|
||||
"input_dtype",
|
||||
"model_type",
|
||||
],
|
||||
advancedFields: [
|
||||
"input_pixel_format",
|
||||
"input_tensor",
|
||||
"input_dtype",
|
||||
"model_type",
|
||||
],
|
||||
hiddenFields: [
|
||||
"labelmap",
|
||||
"attributes_map",
|
||||
"colormap",
|
||||
"all_attributes",
|
||||
"non_logo_attributes",
|
||||
"plus",
|
||||
],
|
||||
uiSchema: {
|
||||
path: {
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
labelmap_path: {
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default model;
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const motion: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/motion_detection",
|
||||
restartRequired: [],
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"threshold",
|
||||
"lightning_threshold",
|
||||
"improve_contrast",
|
||||
"contour_area",
|
||||
"delta_alpha",
|
||||
"frame_alpha",
|
||||
"frame_height",
|
||||
"mqtt_off_delay",
|
||||
],
|
||||
fieldGroups: {
|
||||
sensitivity: ["enabled", "threshold", "contour_area"],
|
||||
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
|
||||
},
|
||||
hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
|
||||
advancedFields: [
|
||||
"lightning_threshold",
|
||||
"delta_alpha",
|
||||
"frame_alpha",
|
||||
"frame_height",
|
||||
"mqtt_off_delay",
|
||||
],
|
||||
},
|
||||
global: {
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"threshold",
|
||||
"lightning_threshold",
|
||||
"improve_contrast",
|
||||
"contour_area",
|
||||
"delta_alpha",
|
||||
"frame_alpha",
|
||||
"frame_height",
|
||||
"mqtt_off_delay",
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["frame_height"],
|
||||
},
|
||||
};
|
||||
|
||||
export default motion;
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const mqtt: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/integrations/mqtt",
|
||||
restartRequired: [],
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"timestamp",
|
||||
"bounding_box",
|
||||
"crop",
|
||||
"height",
|
||||
"required_zones",
|
||||
"quality",
|
||||
],
|
||||
hiddenFields: [],
|
||||
advancedFields: ["height", "quality"],
|
||||
overrideFields: [],
|
||||
uiSchema: {
|
||||
required_zones: {
|
||||
"ui:widget": "zoneNames",
|
||||
},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"host",
|
||||
"port",
|
||||
"user",
|
||||
"password",
|
||||
"topic_prefix",
|
||||
"client_id",
|
||||
"stats_interval",
|
||||
"qos",
|
||||
"tls_ca_certs",
|
||||
"tls_client_cert",
|
||||
"tls_client_key",
|
||||
"tls_insecure",
|
||||
],
|
||||
advancedFields: [
|
||||
"stats_interval",
|
||||
"qos",
|
||||
"tls_ca_certs",
|
||||
"tls_client_cert",
|
||||
"tls_client_key",
|
||||
"tls_insecure",
|
||||
],
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"host",
|
||||
"port",
|
||||
"user",
|
||||
"password",
|
||||
"topic_prefix",
|
||||
"client_id",
|
||||
"stats_interval",
|
||||
"qos",
|
||||
"tls_ca_certs",
|
||||
"tls_client_cert",
|
||||
"tls_client_key",
|
||||
"tls_insecure",
|
||||
],
|
||||
liveValidate: true,
|
||||
uiSchema: {
|
||||
password: {
|
||||
"ui:options": { size: "xs" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default mqtt;
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const networking: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/advanced",
|
||||
fieldDocs: {
|
||||
"listen.internal": "/configuration/advanced#listen-on-different-ports",
|
||||
"listen.external": "/configuration/advanced#listen-on-different-ports",
|
||||
},
|
||||
restartRequired: ["ipv6.enabled", "listen.internal", "listen.external"],
|
||||
fieldOrder: [],
|
||||
advancedFields: [],
|
||||
uiSchema: {
|
||||
"listen.internal": {
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
size: "sm",
|
||||
},
|
||||
},
|
||||
"listen.external": {
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
size: "sm",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default networking;
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const notifications: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/notifications",
|
||||
restartRequired: [],
|
||||
fieldOrder: ["enabled", "email"],
|
||||
fieldGroups: {},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: [],
|
||||
},
|
||||
global: {
|
||||
uiSchema: {
|
||||
"ui:before": { render: "NotificationsSettingsExtras" },
|
||||
enabled: { "ui:widget": "hidden" },
|
||||
email: { "ui:widget": "hidden" },
|
||||
cooldown: { "ui:widget": "hidden" },
|
||||
enabled_in_config: { "ui:widget": "hidden" },
|
||||
},
|
||||
},
|
||||
camera: {
|
||||
hiddenFields: ["enabled_in_config", "email"],
|
||||
},
|
||||
};
|
||||
|
||||
export default notifications;
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const objects: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/object_filters",
|
||||
fieldDocs: {
|
||||
"filters.min_area": "/configuration/object_filters#object-area",
|
||||
"filters.max_area": "/configuration/object_filters#object-area",
|
||||
"filters.min_score": "/configuration/object_filters#minimum-score",
|
||||
"filters.threshold": "/configuration/object_filters#threshold",
|
||||
"filters.min_ratio": "/configuration/object_filters/#object-proportions",
|
||||
"filters.max_ratio": "/configuration/object_filters/#object-proportions",
|
||||
},
|
||||
restartRequired: [],
|
||||
fieldOrder: ["track", "alert", "detect", "filters"],
|
||||
fieldGroups: {
|
||||
tracking: ["track", "alert", "detect"],
|
||||
filtering: ["filters"],
|
||||
},
|
||||
hiddenFields: [
|
||||
"enabled_in_config",
|
||||
"mask",
|
||||
"raw_mask",
|
||||
"genai.enabled_in_config",
|
||||
"filters.*.mask",
|
||||
"filters.*.raw_mask",
|
||||
"filters.mask",
|
||||
"filters.raw_mask",
|
||||
],
|
||||
advancedFields: ["genai"],
|
||||
uiSchema: {
|
||||
"filters.*.min_area": {
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
},
|
||||
},
|
||||
"filters.*": {
|
||||
"ui:options": {
|
||||
additionalPropertyKeyReadonly: true,
|
||||
},
|
||||
},
|
||||
"filters.*.max_area": {
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
},
|
||||
},
|
||||
track: {
|
||||
"ui:widget": "objectLabels",
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
},
|
||||
},
|
||||
genai: {
|
||||
objects: {
|
||||
"ui:widget": "objectLabels",
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
},
|
||||
},
|
||||
prompt: {
|
||||
"ui:widget": "textarea",
|
||||
"ui:options": {
|
||||
size: "full",
|
||||
},
|
||||
},
|
||||
object_prompts: {
|
||||
additionalProperties: {
|
||||
"ui:options": {
|
||||
size: "full",
|
||||
},
|
||||
},
|
||||
},
|
||||
required_zones: {
|
||||
"ui:widget": "zoneNames",
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
},
|
||||
},
|
||||
enabled_in_config: {
|
||||
"ui:widget": "hidden",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["track", "alert", "detect", "filters", "genai"],
|
||||
hiddenFields: [
|
||||
"enabled_in_config",
|
||||
"mask",
|
||||
"raw_mask",
|
||||
"genai.enabled_in_config",
|
||||
"filters.*.mask",
|
||||
"filters.*.raw_mask",
|
||||
"filters.mask",
|
||||
"filters.raw_mask",
|
||||
"genai.required_zones",
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: [],
|
||||
},
|
||||
};
|
||||
|
||||
export default objects;
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const onvif: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/cameras#setting-up-camera-ptz-controls",
|
||||
restartRequired: [
|
||||
"host",
|
||||
"port",
|
||||
"user",
|
||||
"password",
|
||||
"tls_insecure",
|
||||
"ignore_time_mismatch",
|
||||
"autotracking.calibrate_on_startup",
|
||||
],
|
||||
fieldOrder: [
|
||||
"host",
|
||||
"port",
|
||||
"user",
|
||||
"password",
|
||||
"tls_insecure",
|
||||
"ignore_time_mismatch",
|
||||
"autotracking",
|
||||
],
|
||||
hiddenFields: [
|
||||
"autotracking.enabled_in_config",
|
||||
"autotracking.movement_weights",
|
||||
],
|
||||
advancedFields: ["tls_insecure", "ignore_time_mismatch"],
|
||||
overrideFields: [],
|
||||
uiSchema: {
|
||||
host: {
|
||||
"ui:options": { size: "sm" },
|
||||
},
|
||||
autotracking: {
|
||||
required_zones: {
|
||||
"ui:widget": "zoneNames",
|
||||
},
|
||||
track: {
|
||||
"ui:widget": "objectLabels",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default onvif;
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const proxy: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/authentication#proxy",
|
||||
restartRequired: [],
|
||||
fieldOrder: [
|
||||
"header_map",
|
||||
"logout_url",
|
||||
"auth_secret",
|
||||
"default_role",
|
||||
"separator",
|
||||
],
|
||||
advancedFields: ["header_map", "auth_secret", "separator"],
|
||||
liveValidate: true,
|
||||
uiSchema: {
|
||||
logout_url: {
|
||||
"ui:options": { size: "lg" },
|
||||
},
|
||||
auth_secret: {
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
header_map: {
|
||||
"ui:after": { render: "ProxyRoleMap" },
|
||||
},
|
||||
"header_map.role_map": {
|
||||
"ui:widget": "hidden",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default proxy;
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const record: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/record",
|
||||
restartRequired: [],
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"expire_interval",
|
||||
"continuous",
|
||||
"motion",
|
||||
"alerts",
|
||||
"detections",
|
||||
"preview",
|
||||
"export",
|
||||
],
|
||||
fieldGroups: {
|
||||
retention: ["enabled", "continuous", "motion"],
|
||||
events: ["alerts", "detections"],
|
||||
},
|
||||
hiddenFields: ["enabled_in_config", "sync_recordings"],
|
||||
advancedFields: ["expire_interval", "preview", "export"],
|
||||
uiSchema: {
|
||||
export: {
|
||||
hwaccel_args: {
|
||||
"ui:options": { size: "lg" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"expire_interval",
|
||||
"continuous",
|
||||
"motion",
|
||||
"alerts",
|
||||
"detections",
|
||||
"preview",
|
||||
"export",
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: [],
|
||||
},
|
||||
};
|
||||
|
||||
export default record;
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const review: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/review",
|
||||
restartRequired: [],
|
||||
fieldOrder: ["alerts", "detections", "genai"],
|
||||
fieldGroups: {},
|
||||
hiddenFields: [
|
||||
"enabled_in_config",
|
||||
"alerts.labels",
|
||||
"alerts.enabled_in_config",
|
||||
"detections.labels",
|
||||
"detections.enabled_in_config",
|
||||
"genai.enabled_in_config",
|
||||
],
|
||||
advancedFields: [],
|
||||
uiSchema: {
|
||||
alerts: {
|
||||
"ui:before": { render: "CameraReviewStatusToggles" },
|
||||
required_zones: {
|
||||
"ui:widget": "hidden",
|
||||
},
|
||||
},
|
||||
detections: {
|
||||
required_zones: {
|
||||
"ui:widget": "hidden",
|
||||
},
|
||||
},
|
||||
genai: {
|
||||
additional_concerns: {
|
||||
"ui:widget": "textarea",
|
||||
"ui:options": {
|
||||
size: "full",
|
||||
},
|
||||
},
|
||||
activity_context_prompt: {
|
||||
"ui:widget": "textarea",
|
||||
"ui:options": {
|
||||
size: "full",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["alerts", "detections", "genai"],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: [],
|
||||
},
|
||||
};
|
||||
|
||||
export default review;
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const semanticSearch: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/semantic_search",
|
||||
restartRequired: [],
|
||||
hiddenFields: [],
|
||||
advancedFields: [],
|
||||
overrideFields: [],
|
||||
uiSchema: {
|
||||
enabled: {
|
||||
"ui:after": { render: "SemanticSearchReindex" },
|
||||
},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
fieldOrder: ["enabled", "reindex", "model", "model_size", "device"],
|
||||
advancedFields: ["reindex", "device"],
|
||||
restartRequired: ["enabled", "model", "model_size", "device"],
|
||||
hiddenFields: ["reindex"],
|
||||
},
|
||||
};
|
||||
|
||||
export default semanticSearch;
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const snapshots: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/snapshots",
|
||||
restartRequired: [],
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
"bounding_box",
|
||||
"crop",
|
||||
"quality",
|
||||
"timestamp",
|
||||
"retain",
|
||||
],
|
||||
fieldGroups: {
|
||||
display: ["enabled", "bounding_box", "crop", "quality", "timestamp"],
|
||||
},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: ["height", "quality", "retain"],
|
||||
uiSchema: {
|
||||
required_zones: {
|
||||
"ui:widget": "zoneNames",
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: [
|
||||
"enabled",
|
||||
"bounding_box",
|
||||
"crop",
|
||||
"quality",
|
||||
"timestamp",
|
||||
"retain",
|
||||
],
|
||||
hiddenFields: ["enabled_in_config", "required_zones"],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: [],
|
||||
},
|
||||
};
|
||||
|
||||
export default snapshots;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const telemetry: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/reference",
|
||||
restartRequired: [
|
||||
"network_interfaces",
|
||||
"stats.amd_gpu_stats",
|
||||
"stats.intel_gpu_stats",
|
||||
"stats.intel_gpu_device",
|
||||
"stats.network_bandwidth",
|
||||
"version_check",
|
||||
],
|
||||
fieldOrder: ["network_interfaces", "stats", "version_check"],
|
||||
advancedFields: [],
|
||||
},
|
||||
};
|
||||
|
||||
export default telemetry;
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const timestampStyle: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/reference",
|
||||
restartRequired: [],
|
||||
fieldOrder: ["position", "format", "color", "thickness"],
|
||||
hiddenFields: ["effect", "enabled_in_config"],
|
||||
advancedFields: [],
|
||||
uiSchema: {
|
||||
position: {
|
||||
"ui:size": "xs",
|
||||
},
|
||||
format: {
|
||||
"ui:size": "xs",
|
||||
},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["position", "format", "color", "thickness", "effect"],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: [],
|
||||
},
|
||||
};
|
||||
|
||||
export default timestampStyle;
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const tls: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/tls",
|
||||
restartRequired: ["enabled"],
|
||||
fieldOrder: ["enabled", "cert", "key"],
|
||||
advancedFields: [],
|
||||
uiSchema: {
|
||||
cert: {
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
key: {
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default tls;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { SectionConfig } from "../sections/BaseSection";
|
||||
|
||||
export type SectionConfigOverrides = {
|
||||
base?: SectionConfig;
|
||||
global?: Partial<SectionConfig>;
|
||||
camera?: Partial<SectionConfig>;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const ui: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/reference",
|
||||
restartRequired: [],
|
||||
fieldOrder: ["dashboard", "order"],
|
||||
hiddenFields: [],
|
||||
advancedFields: [],
|
||||
overrideFields: [],
|
||||
},
|
||||
global: {
|
||||
fieldOrder: [
|
||||
"timezone",
|
||||
"time_format",
|
||||
"date_style",
|
||||
"time_style",
|
||||
"unit_system",
|
||||
],
|
||||
advancedFields: [],
|
||||
restartRequired: ["unit_system"],
|
||||
uiSchema: {
|
||||
timezone: {
|
||||
"ui:widget": "timezoneSelect",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default ui;
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { FormValidation } from "@rjsf/utils";
|
||||
import type { TFunction } from "i18next";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import type { JsonObject } from "@/types/configForm";
|
||||
|
||||
function hasValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function validateFfmpegInputRoles(
|
||||
formData: unknown,
|
||||
errors: FormValidation,
|
||||
t: TFunction,
|
||||
): FormValidation {
|
||||
if (!isJsonObject(formData as JsonObject)) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const inputs = (formData as JsonObject).inputs;
|
||||
if (!Array.isArray(inputs)) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const roleCounts = new Map<string, number>();
|
||||
let hasDetect = false;
|
||||
let hasInvalidHwaccel = false;
|
||||
inputs.forEach((input) => {
|
||||
if (!isJsonObject(input) || !Array.isArray(input.roles)) {
|
||||
return;
|
||||
}
|
||||
input.roles.forEach((role) => {
|
||||
if (typeof role !== "string") {
|
||||
return;
|
||||
}
|
||||
roleCounts.set(role, (roleCounts.get(role) || 0) + 1);
|
||||
});
|
||||
if (input.roles.includes("detect")) {
|
||||
hasDetect = true;
|
||||
} else if (hasValue(input.hwaccel_args)) {
|
||||
hasInvalidHwaccel = true;
|
||||
}
|
||||
});
|
||||
|
||||
const hasDuplicates = Array.from(roleCounts.values()).some(
|
||||
(count) => count > 1,
|
||||
);
|
||||
|
||||
if (hasDuplicates) {
|
||||
const inputsErrors = errors.inputs as {
|
||||
addError?: (message: string) => void;
|
||||
};
|
||||
inputsErrors?.addError?.(
|
||||
t("ffmpeg.inputs.rolesUnique", { ns: "config/validation" }),
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasDetect) {
|
||||
const inputsErrors = errors.inputs as {
|
||||
addError?: (message: string) => void;
|
||||
};
|
||||
inputsErrors?.addError?.(
|
||||
t("ffmpeg.inputs.detectRequired", { ns: "config/validation" }),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasInvalidHwaccel) {
|
||||
const inputsErrors = errors.inputs as {
|
||||
addError?: (message: string) => void;
|
||||
};
|
||||
inputsErrors?.addError?.(
|
||||
t("ffmpeg.inputs.hwaccelDetectOnly", { ns: "config/validation" }),
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { FormValidation } from "@rjsf/utils";
|
||||
import type { TFunction } from "i18next";
|
||||
import { validateFfmpegInputRoles } from "./ffmpeg";
|
||||
import { validateProxyRoleHeader } from "./proxy";
|
||||
|
||||
export type SectionValidation = (
|
||||
formData: unknown,
|
||||
errors: FormValidation,
|
||||
) => FormValidation;
|
||||
|
||||
type SectionValidationOptions = {
|
||||
sectionPath: string;
|
||||
level: "global" | "camera";
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
export function getSectionValidation({
|
||||
sectionPath,
|
||||
level,
|
||||
t,
|
||||
}: SectionValidationOptions): SectionValidation | undefined {
|
||||
if (sectionPath === "ffmpeg" && level === "camera") {
|
||||
return (formData, errors) => validateFfmpegInputRoles(formData, errors, t);
|
||||
}
|
||||
|
||||
if (sectionPath === "proxy" && level === "global") {
|
||||
return (formData, errors) => validateProxyRoleHeader(formData, errors, t);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { FormValidation } from "@rjsf/utils";
|
||||
import type { TFunction } from "i18next";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import type { JsonObject } from "@/types/configForm";
|
||||
|
||||
export function validateProxyRoleHeader(
|
||||
formData: unknown,
|
||||
errors: FormValidation,
|
||||
t: TFunction,
|
||||
): FormValidation {
|
||||
if (!isJsonObject(formData as JsonObject)) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const headerMap = (formData as JsonObject).header_map;
|
||||
if (!isJsonObject(headerMap)) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const roleHeader = headerMap.role;
|
||||
const roleHeaderDefined =
|
||||
typeof roleHeader === "string" && roleHeader.trim().length > 0;
|
||||
const roleMap = headerMap.role_map;
|
||||
const roleMapHasEntries =
|
||||
isJsonObject(roleMap) && Object.keys(roleMap).length > 0;
|
||||
|
||||
if (roleMapHasEntries && !roleHeaderDefined) {
|
||||
const headerMapErrors = errors.header_map as {
|
||||
role?: { addError?: (message: string) => void };
|
||||
};
|
||||
headerMapErrors?.role?.addError?.(
|
||||
t("proxy.header_map.roleHeaderRequired", { ns: "config/validation" }),
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
sectionConfigs.ts — section configuration overrides
|
||||
|
||||
Purpose:
|
||||
- Centralize UI configuration hints for each config section (field ordering,
|
||||
grouping, hidden/advanced fields, uiSchema overrides, and overrideFields).
|
||||
|
||||
Shape:
|
||||
- Each section key maps to an object with optional `base`, `global`, and
|
||||
`camera` entries where each is a `SectionConfig` (or partial):
|
||||
{
|
||||
base?: SectionConfig; // common defaults (typically camera-level)
|
||||
global?: Partial<SectionConfig>; // overrides for global-level UI
|
||||
camera?: Partial<SectionConfig>; // overrides for camera-level UI
|
||||
}
|
||||
*/
|
||||
|
||||
import type { SectionConfigOverrides } from "./section-configs/types";
|
||||
import audio from "./section-configs/audio";
|
||||
import audioTranscription from "./section-configs/audio_transcription";
|
||||
import auth from "./section-configs/auth";
|
||||
import birdseye from "./section-configs/birdseye";
|
||||
import classification from "./section-configs/classification";
|
||||
import database from "./section-configs/database";
|
||||
import detect from "./section-configs/detect";
|
||||
import detectors from "./section-configs/detectors";
|
||||
import environmentVars from "./section-configs/environment_vars";
|
||||
import faceRecognition from "./section-configs/face_recognition";
|
||||
import ffmpeg from "./section-configs/ffmpeg";
|
||||
import genai from "./section-configs/genai";
|
||||
import live from "./section-configs/live";
|
||||
import logger from "./section-configs/logger";
|
||||
import lpr from "./section-configs/lpr";
|
||||
import model from "./section-configs/model";
|
||||
import motion from "./section-configs/motion";
|
||||
import mqtt from "./section-configs/mqtt";
|
||||
import networking from "./section-configs/networking";
|
||||
import notifications from "./section-configs/notifications";
|
||||
import objects from "./section-configs/objects";
|
||||
import onvif from "./section-configs/onvif";
|
||||
import proxy from "./section-configs/proxy";
|
||||
import record from "./section-configs/record";
|
||||
import review from "./section-configs/review";
|
||||
import semanticSearch from "./section-configs/semantic_search";
|
||||
import snapshots from "./section-configs/snapshots";
|
||||
import telemetry from "./section-configs/telemetry";
|
||||
import timestampStyle from "./section-configs/timestamp_style";
|
||||
import tls from "./section-configs/tls";
|
||||
import ui from "./section-configs/ui";
|
||||
|
||||
export const sectionConfigs: Record<string, SectionConfigOverrides> = {
|
||||
detect,
|
||||
record,
|
||||
snapshots,
|
||||
motion,
|
||||
objects,
|
||||
review,
|
||||
audio,
|
||||
live,
|
||||
timestamp_style: timestampStyle,
|
||||
notifications,
|
||||
onvif,
|
||||
ffmpeg,
|
||||
audio_transcription: audioTranscription,
|
||||
birdseye,
|
||||
face_recognition: faceRecognition,
|
||||
lpr,
|
||||
semantic_search: semanticSearch,
|
||||
mqtt,
|
||||
ui,
|
||||
database,
|
||||
auth,
|
||||
tls,
|
||||
networking,
|
||||
proxy,
|
||||
logger,
|
||||
environment_vars: environmentVars,
|
||||
telemetry,
|
||||
detectors,
|
||||
model,
|
||||
genai,
|
||||
classification,
|
||||
};
|
||||
|
||||
export type { SectionConfigOverrides } from "./section-configs/types";
|
||||
@@ -0,0 +1,403 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import get from "lodash/get";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import set from "lodash/set";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { formatList } from "@/utils/stringUtil";
|
||||
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
|
||||
import type { SectionRendererProps } from "./registry";
|
||||
|
||||
const EMPTY_ZONES: string[] = [];
|
||||
|
||||
function getRequiredZones(
|
||||
formData: JsonObject | undefined,
|
||||
path: string,
|
||||
): string[] {
|
||||
const value = get(formData, path);
|
||||
return Array.isArray(value) ? (value as string[]) : EMPTY_ZONES;
|
||||
}
|
||||
|
||||
export default function CameraReviewClassification({
|
||||
formContext,
|
||||
selectedCamera,
|
||||
}: SectionRendererProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const cameraName = formContext?.cameraName ?? selectedCamera;
|
||||
const fullFormData = formContext?.formData as JsonObject | undefined;
|
||||
const baselineFormData = formContext?.baselineFormData as
|
||||
| JsonObject
|
||||
| undefined;
|
||||
const cameraConfig = formContext?.fullCameraConfig;
|
||||
|
||||
const alertsZones = useMemo(
|
||||
() => getRequiredZones(fullFormData, "alerts.required_zones"),
|
||||
[fullFormData],
|
||||
);
|
||||
const detectionsZones = useMemo(
|
||||
() => getRequiredZones(fullFormData, "detections.required_zones"),
|
||||
[fullFormData],
|
||||
);
|
||||
|
||||
// Track whether zones have been modified from baseline for label coloring
|
||||
const alertsZonesModified = useMemo(() => {
|
||||
if (!baselineFormData) return false;
|
||||
const baseline = getRequiredZones(
|
||||
baselineFormData,
|
||||
"alerts.required_zones",
|
||||
);
|
||||
return !isEqual(alertsZones, baseline);
|
||||
}, [alertsZones, baselineFormData]);
|
||||
|
||||
const detectionsZonesModified = useMemo(() => {
|
||||
if (!baselineFormData) return false;
|
||||
const baseline = getRequiredZones(
|
||||
baselineFormData,
|
||||
"detections.required_zones",
|
||||
);
|
||||
return !isEqual(detectionsZones, baseline);
|
||||
}, [detectionsZones, baselineFormData]);
|
||||
|
||||
const [selectDetections, setSelectDetections] = useState(
|
||||
detectionsZones.length > 0,
|
||||
);
|
||||
const previousCameraRef = useRef(cameraName);
|
||||
const isSynced = formContext?.hasChanges === false;
|
||||
|
||||
useEffect(() => {
|
||||
const cameraChanged = previousCameraRef.current !== cameraName;
|
||||
if (cameraChanged) {
|
||||
previousCameraRef.current = cameraName;
|
||||
}
|
||||
|
||||
if (cameraChanged || isSynced) {
|
||||
setSelectDetections(detectionsZones.length > 0);
|
||||
}
|
||||
}, [cameraName, detectionsZones.length, isSynced]);
|
||||
|
||||
const zones = useMemo(() => {
|
||||
if (!cameraConfig) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.entries(cameraConfig.zones).map(([name, zoneData]) => {
|
||||
const zone =
|
||||
zoneData as (typeof cameraConfig.zones)[keyof typeof cameraConfig.zones];
|
||||
return {
|
||||
camera: cameraConfig.name,
|
||||
name,
|
||||
friendly_name: cameraConfig.zones[name].friendly_name,
|
||||
objects: zone.objects,
|
||||
color: zone.color,
|
||||
};
|
||||
});
|
||||
}, [cameraConfig]);
|
||||
|
||||
const alertsLabels = useMemo(() => {
|
||||
return cameraConfig?.review.alerts.labels
|
||||
? formatList(
|
||||
cameraConfig.review.alerts.labels.map((label: string) =>
|
||||
getTranslatedLabel(
|
||||
label,
|
||||
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
|
||||
),
|
||||
),
|
||||
)
|
||||
: "";
|
||||
}, [cameraConfig]);
|
||||
|
||||
const detectionsLabels = useMemo(() => {
|
||||
return cameraConfig?.review.detections.labels
|
||||
? formatList(
|
||||
cameraConfig.review.detections.labels.map((label: string) =>
|
||||
getTranslatedLabel(
|
||||
label,
|
||||
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
|
||||
),
|
||||
),
|
||||
)
|
||||
: "";
|
||||
}, [cameraConfig]);
|
||||
|
||||
const selectCameraName = useCameraFriendlyName(cameraName);
|
||||
|
||||
const getZoneName = useCallback(
|
||||
(zoneId: string, camId?: string) =>
|
||||
resolveZoneName(formContext?.fullConfig, zoneId, camId),
|
||||
[formContext?.fullConfig],
|
||||
);
|
||||
|
||||
const updateFormData = useCallback(
|
||||
(path: string, nextValue: string[]) => {
|
||||
if (!formContext?.onFormDataChange || !fullFormData) {
|
||||
return;
|
||||
}
|
||||
const nextData = cloneDeep(fullFormData) as JsonObject;
|
||||
set(nextData, path, nextValue);
|
||||
formContext.onFormDataChange(nextData as ConfigSectionData);
|
||||
},
|
||||
[formContext, fullFormData],
|
||||
);
|
||||
|
||||
const handleZoneToggle = useCallback(
|
||||
(path: string, zoneName: string) => {
|
||||
const currentZones = getRequiredZones(fullFormData, path);
|
||||
const nextZones = currentZones.includes(zoneName)
|
||||
? currentZones.filter((value) => value !== zoneName)
|
||||
: [...currentZones, zoneName];
|
||||
updateFormData(path, nextZones);
|
||||
},
|
||||
[fullFormData, updateFormData],
|
||||
);
|
||||
|
||||
const handleDetectionsToggle = useCallback(
|
||||
(checked: boolean | string) => {
|
||||
const isChecked = checked === true;
|
||||
if (!isChecked) {
|
||||
updateFormData("detections.required_zones", []);
|
||||
}
|
||||
setSelectDetections(isChecked);
|
||||
},
|
||||
[updateFormData],
|
||||
);
|
||||
|
||||
if (!cameraName || formContext?.level !== "camera") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 space-y-6">
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.title
|
||||
</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.desc
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/review")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"w-full max-w-5xl space-y-0",
|
||||
zones && zones.length > 0 && "grid items-start gap-5 md:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{zones && zones.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<Label
|
||||
className={cn(
|
||||
"flex flex-row items-center text-base",
|
||||
alertsZonesModified && "text-danger",
|
||||
)}
|
||||
>
|
||||
<Trans ns="views/settings">cameraReview.review.alerts</Trans>
|
||||
<MdCircle className="ml-3 size-2 text-severity_alert" />
|
||||
</Label>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.selectAlertsZones
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
|
||||
{zones.map((zone) => (
|
||||
<div
|
||||
key={zone.name}
|
||||
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
|
||||
>
|
||||
<Checkbox
|
||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||
checked={alertsZones.includes(zone.name)}
|
||||
onCheckedChange={() =>
|
||||
handleZoneToggle("alerts.required_zones", zone.name)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
className={cn(
|
||||
"font-normal",
|
||||
!zone.friendly_name && "smart-capitalize",
|
||||
)}
|
||||
>
|
||||
{zone.friendly_name || zone.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="font-normal text-destructive">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.noDefinedZones
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 text-sm">
|
||||
{alertsZones.length > 0
|
||||
? t("cameraReview.reviewClassification.zoneObjectAlertsTips", {
|
||||
alertsLabels,
|
||||
zone: formatList(
|
||||
alertsZones.map((zone) => getZoneName(zone, cameraName)),
|
||||
),
|
||||
cameraName: selectCameraName,
|
||||
})
|
||||
: t("cameraReview.reviewClassification.objectAlertsTips", {
|
||||
alertsLabels,
|
||||
cameraName: selectCameraName,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{zones && zones.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<Label
|
||||
className={cn(
|
||||
"flex flex-row items-center text-base",
|
||||
detectionsZonesModified && "text-danger",
|
||||
)}
|
||||
>
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.review.detections
|
||||
</Trans>
|
||||
<MdCircle className="ml-3 size-2 text-severity_detection" />
|
||||
</Label>
|
||||
{selectDetections && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.selectDetectionsZones
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectDetections && (
|
||||
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
|
||||
{zones.map((zone) => (
|
||||
<div
|
||||
key={zone.name}
|
||||
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
|
||||
>
|
||||
<Checkbox
|
||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||
checked={detectionsZones.includes(zone.name)}
|
||||
onCheckedChange={() =>
|
||||
handleZoneToggle(
|
||||
"detections.required_zones",
|
||||
zone.name,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
className={cn(
|
||||
"font-normal",
|
||||
!zone.friendly_name && "smart-capitalize",
|
||||
)}
|
||||
>
|
||||
{zone.friendly_name || zone.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-0 mt-3 flex flex-row items-center gap-2">
|
||||
<Checkbox
|
||||
id="select-detections"
|
||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||
checked={selectDetections}
|
||||
onCheckedChange={handleDetectionsToggle}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="select-detections"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.reviewClassification.limitDetections
|
||||
</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-2 text-sm">
|
||||
{detectionsZones.length > 0 ? (
|
||||
!selectDetections ? (
|
||||
<Trans
|
||||
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
zone: formatList(
|
||||
detectionsZones.map((zone) =>
|
||||
getZoneName(zone, cameraName),
|
||||
),
|
||||
),
|
||||
cameraName: selectCameraName,
|
||||
}}
|
||||
ns="views/settings"
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
zone: formatList(
|
||||
detectionsZones.map((zone) =>
|
||||
getZoneName(zone, cameraName),
|
||||
),
|
||||
),
|
||||
cameraName: selectCameraName,
|
||||
}}
|
||||
ns="views/settings"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="cameraReview.reviewClassification.objectDetectionsTips"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
cameraName: selectCameraName,
|
||||
}}
|
||||
ns="views/settings"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import { Trans } from "react-i18next";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
useAlertsState,
|
||||
useDetectionsState,
|
||||
useObjectDescriptionState,
|
||||
useReviewDescriptionState,
|
||||
} from "@/api/ws";
|
||||
import type { SectionRendererProps } from "./registry";
|
||||
import CameraReviewClassification from "./CameraReviewClassification";
|
||||
|
||||
export default function CameraReviewStatusToggles({
|
||||
selectedCamera,
|
||||
formContext,
|
||||
}: SectionRendererProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const cameraId = selectedCamera ?? "";
|
||||
|
||||
const cameraConfig = useMemo(() => {
|
||||
if (config && selectedCamera) {
|
||||
return config.cameras[selectedCamera];
|
||||
}
|
||||
}, [config, selectedCamera]);
|
||||
|
||||
const { payload: alertsState, send: sendAlerts } = useAlertsState(cameraId);
|
||||
const { payload: detectionsState, send: sendDetections } =
|
||||
useDetectionsState(cameraId);
|
||||
|
||||
const { payload: objDescState, send: sendObjDesc } =
|
||||
useObjectDescriptionState(cameraId);
|
||||
const { payload: revDescState, send: sendRevDesc } =
|
||||
useReviewDescriptionState(cameraId);
|
||||
|
||||
if (!selectedCamera || !cameraConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">cameraReview.title</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id="alerts-enabled"
|
||||
className="mr-3"
|
||||
checked={alertsState == "ON"}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendAlerts(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="alerts-enabled">
|
||||
<Trans ns="views/settings">cameraReview.review.alerts</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id="detections-enabled"
|
||||
className="mr-3"
|
||||
checked={detectionsState == "ON"}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendDetections(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="detections-enabled">
|
||||
<Trans ns="views/settings">camera.review.detections</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
<Trans ns="views/settings">cameraReview.review.desc</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cameraConfig?.objects?.genai?.enabled_in_config && (
|
||||
<>
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.object_descriptions.title
|
||||
</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id="object-descriptions-enabled"
|
||||
className="mr-3"
|
||||
checked={objDescState == "ON"}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendObjDesc(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="object-descriptions-enabled">
|
||||
<Trans>button.enabled</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.object_descriptions.desc
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{cameraConfig?.review?.genai?.enabled_in_config && (
|
||||
<>
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
|
||||
<Heading as="h4" className="my-2">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.review_descriptions.title
|
||||
</Trans>
|
||||
</Heading>
|
||||
|
||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
|
||||
<div className="flex flex-row items-center">
|
||||
<Switch
|
||||
id="review-descriptions-enabled"
|
||||
className="mr-3"
|
||||
checked={revDescState == "ON"}
|
||||
onCheckedChange={(isChecked) => {
|
||||
sendRevDesc(isChecked ? "ON" : "OFF");
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="review-descriptions-enabled">
|
||||
<Trans>button.enabled</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
<Trans ns="views/settings">
|
||||
cameraReview.review_descriptions.desc
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CameraReviewClassification
|
||||
selectedCamera={selectedCamera}
|
||||
formContext={formContext}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import axios from "axios";
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
|
||||
import { CiCircleAlert } from "react-icons/ci";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
useNotifications,
|
||||
useNotificationSuspend,
|
||||
useNotificationTest,
|
||||
} from "@/api/ws";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { cn } from "@/lib/utils";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import set from "lodash/set";
|
||||
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
|
||||
import { sanitizeSectionData } from "@/utils/configUtil";
|
||||
import type { SectionRendererProps } from "./registry";
|
||||
|
||||
const NOTIFICATION_SERVICE_WORKER = "/notification-worker.js";
|
||||
import {
|
||||
SettingsGroupCard,
|
||||
SPLIT_ROW_CLASS_NAME,
|
||||
CONTROL_COLUMN_CLASS_NAME,
|
||||
} from "@/components/card/SettingsGroupCard";
|
||||
|
||||
export default function NotificationsSettingsExtras({
|
||||
formContext,
|
||||
}: SectionRendererProps) {
|
||||
const { t } = useTranslation([
|
||||
"views/settings",
|
||||
"common",
|
||||
"components/filter",
|
||||
]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
|
||||
// roles
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
// status bar
|
||||
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
||||
|
||||
// config
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const allCameras = useMemo(() => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.values(config.cameras)
|
||||
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order)
|
||||
.filter((c) => c.enabled_in_config);
|
||||
}, [config]);
|
||||
|
||||
const notificationCameras = useMemo(() => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.values(config.cameras)
|
||||
.filter(
|
||||
(conf) =>
|
||||
conf.enabled_in_config &&
|
||||
conf.notifications &&
|
||||
conf.notifications.enabled_in_config,
|
||||
)
|
||||
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||
}, [config]);
|
||||
|
||||
const { send: sendTestNotification } = useNotificationTest();
|
||||
|
||||
// notification state
|
||||
const [registration, setRegistration] =
|
||||
useState<ServiceWorkerRegistration | null>();
|
||||
const [cameraSelectionTouched, setCameraSelectionTouched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!("Notification" in window) || !window.isSecureContext) {
|
||||
return;
|
||||
}
|
||||
navigator.serviceWorker
|
||||
.getRegistration(NOTIFICATION_SERVICE_WORKER)
|
||||
.then((worker) => {
|
||||
if (worker) {
|
||||
setRegistration(worker);
|
||||
} else {
|
||||
setRegistration(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setRegistration(null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// form
|
||||
const formSchema = z.object({
|
||||
allEnabled: z.boolean(),
|
||||
email: z.string(),
|
||||
cameras: z.array(z.string()),
|
||||
});
|
||||
|
||||
const pendingDataBySection = useMemo(
|
||||
() => formContext?.pendingDataBySection ?? {},
|
||||
[formContext?.pendingDataBySection],
|
||||
);
|
||||
const pendingCameraOverrides = useMemo(() => {
|
||||
const overrides: Record<string, boolean> = {};
|
||||
Object.entries(pendingDataBySection).forEach(([key, data]) => {
|
||||
if (!key.endsWith("::notifications")) {
|
||||
return;
|
||||
}
|
||||
const cameraName = key.slice(0, key.indexOf("::"));
|
||||
const enabled = (data as JsonObject | undefined)?.enabled;
|
||||
if (typeof enabled === "boolean") {
|
||||
overrides[cameraName] = enabled;
|
||||
}
|
||||
});
|
||||
return overrides;
|
||||
}, [pendingDataBySection]);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const formData = formContext?.formData as JsonObject | undefined;
|
||||
const enabledValue =
|
||||
typeof formData?.enabled === "boolean"
|
||||
? formData.enabled
|
||||
: (config?.notifications.enabled ?? false);
|
||||
const emailValue =
|
||||
typeof formData?.email === "string"
|
||||
? formData.email
|
||||
: (config?.notifications.email ?? "");
|
||||
const baseEnabledSet = new Set(
|
||||
notificationCameras.map((camera) => camera.name),
|
||||
);
|
||||
const selectedCameras = enabledValue
|
||||
? []
|
||||
: allCameras
|
||||
.filter((camera) => {
|
||||
const pendingEnabled = pendingCameraOverrides[camera.name];
|
||||
if (typeof pendingEnabled === "boolean") {
|
||||
return pendingEnabled;
|
||||
}
|
||||
return baseEnabledSet.has(camera.name);
|
||||
})
|
||||
.map((camera) => camera.name);
|
||||
|
||||
return {
|
||||
allEnabled: Boolean(enabledValue),
|
||||
email: typeof emailValue === "string" ? emailValue : "",
|
||||
cameras: selectedCameras,
|
||||
};
|
||||
}, [
|
||||
allCameras,
|
||||
config?.notifications.email,
|
||||
config?.notifications.enabled,
|
||||
formContext?.formData,
|
||||
notificationCameras,
|
||||
pendingCameraOverrides,
|
||||
]);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onChange",
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const watchAllEnabled = form.watch("allEnabled");
|
||||
const watchCameras = form.watch("cameras");
|
||||
const watchEmail = form.watch("email");
|
||||
const pendingCameraOverridesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const resetFormState = useCallback(
|
||||
(values: z.infer<typeof formSchema>) => {
|
||||
form.reset(values);
|
||||
setCameraSelectionTouched(false);
|
||||
pendingCameraOverridesRef.current.clear();
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
// pending changes sync (Undo All / Save All)
|
||||
const hasPendingNotifications = useMemo(
|
||||
() =>
|
||||
Object.keys(pendingDataBySection).some(
|
||||
(key) => key === "notifications" || key.endsWith("::notifications"),
|
||||
),
|
||||
[pendingDataBySection],
|
||||
);
|
||||
const hasPendingNotificationsRef = useRef(hasPendingNotifications);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config || form.formState.isDirty || hasPendingNotifications) {
|
||||
return;
|
||||
}
|
||||
resetFormState(defaultValues);
|
||||
}, [
|
||||
config,
|
||||
defaultValues,
|
||||
form.formState.isDirty,
|
||||
hasPendingNotifications,
|
||||
resetFormState,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const hadPending = hasPendingNotificationsRef.current;
|
||||
hasPendingNotificationsRef.current = hasPendingNotifications;
|
||||
|
||||
if (hadPending && !hasPendingNotifications) {
|
||||
resetFormState(defaultValues);
|
||||
}
|
||||
}, [hasPendingNotifications, defaultValues, resetFormState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formContext?.onFormDataChange) {
|
||||
return;
|
||||
}
|
||||
const baseData =
|
||||
(formContext.formData as JsonObject | undefined) ??
|
||||
(config?.notifications as JsonObject | undefined);
|
||||
if (!baseData) {
|
||||
return;
|
||||
}
|
||||
const nextData = cloneDeep(baseData);
|
||||
const normalizedEmail = watchEmail?.trim() ? watchEmail : null;
|
||||
set(nextData, "enabled", Boolean(watchAllEnabled));
|
||||
set(nextData, "email", normalizedEmail);
|
||||
formContext.onFormDataChange(nextData as ConfigSectionData);
|
||||
}, [config, formContext, watchAllEnabled, watchEmail]);
|
||||
|
||||
// camera selection overrides
|
||||
const baselineCameraSelection = useMemo(() => {
|
||||
if (!config) {
|
||||
return [] as string[];
|
||||
}
|
||||
return config.notifications.enabled
|
||||
? []
|
||||
: notificationCameras.map((camera) => camera.name);
|
||||
}, [config, notificationCameras]);
|
||||
|
||||
const cameraSelectionDirty = useMemo(() => {
|
||||
const current = Array.isArray(watchCameras) ? watchCameras : [];
|
||||
return !isEqual([...current].sort(), [...baselineCameraSelection].sort());
|
||||
}, [watchCameras, baselineCameraSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
formContext?.setExtraHasChanges?.(cameraSelectionDirty);
|
||||
}, [cameraSelectionDirty, formContext]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPendingDataChange = formContext?.onPendingDataChange;
|
||||
if (!onPendingDataChange || !config) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cameraSelectionTouched) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cameraSelectionDirty) {
|
||||
pendingCameraOverridesRef.current.forEach((cameraName) => {
|
||||
onPendingDataChange("notifications", cameraName, null);
|
||||
});
|
||||
pendingCameraOverridesRef.current.clear();
|
||||
setCameraSelectionTouched(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCameras = Array.isArray(watchCameras) ? watchCameras : [];
|
||||
|
||||
allCameras.forEach((camera) => {
|
||||
const desiredEnabled = watchAllEnabled
|
||||
? true
|
||||
: selectedCameras.includes(camera.name);
|
||||
const currentNotifications = config.cameras[camera.name]?.notifications;
|
||||
const currentEnabled = currentNotifications?.enabled;
|
||||
|
||||
if (desiredEnabled === currentEnabled) {
|
||||
if (pendingCameraOverridesRef.current.has(camera.name)) {
|
||||
onPendingDataChange("notifications", camera.name, null);
|
||||
pendingCameraOverridesRef.current.delete(camera.name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentNotifications) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextNotifications = cloneDeep(
|
||||
currentNotifications as JsonObject,
|
||||
) as JsonObject;
|
||||
set(nextNotifications, "enabled", desiredEnabled);
|
||||
const sanitizedNotifications = sanitizeSectionData(
|
||||
nextNotifications as ConfigSectionData,
|
||||
["enabled_in_config", "email"],
|
||||
);
|
||||
onPendingDataChange("notifications", camera.name, sanitizedNotifications);
|
||||
pendingCameraOverridesRef.current.add(camera.name);
|
||||
});
|
||||
}, [
|
||||
allCameras,
|
||||
cameraSelectionDirty,
|
||||
cameraSelectionTouched,
|
||||
config,
|
||||
formContext,
|
||||
watchAllEnabled,
|
||||
watchCameras,
|
||||
]);
|
||||
|
||||
const anyCameraNotificationsEnabled = useMemo(
|
||||
() =>
|
||||
config &&
|
||||
Object.values(config.cameras).some(
|
||||
(c) =>
|
||||
c.enabled_in_config &&
|
||||
c.notifications &&
|
||||
c.notifications.enabled_in_config,
|
||||
),
|
||||
[config],
|
||||
);
|
||||
|
||||
const shouldFetchPubKey = Boolean(
|
||||
config &&
|
||||
(config.notifications?.enabled || anyCameraNotificationsEnabled) &&
|
||||
(watchAllEnabled ||
|
||||
(Array.isArray(watchCameras) && watchCameras.length > 0)),
|
||||
);
|
||||
|
||||
const { data: publicKey } = useSWR(
|
||||
shouldFetchPubKey ? "notifications/pubkey" : null,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
const subscribeToNotifications = useCallback(
|
||||
(workerRegistration: ServiceWorkerRegistration) => {
|
||||
if (!workerRegistration) {
|
||||
return;
|
||||
}
|
||||
|
||||
addMessage(
|
||||
"notification_settings",
|
||||
t("notification.unsavedRegistrations"),
|
||||
undefined,
|
||||
"registration",
|
||||
);
|
||||
|
||||
workerRegistration.pushManager
|
||||
.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: publicKey,
|
||||
})
|
||||
.then((pushSubscription) => {
|
||||
axios
|
||||
.post("notifications/register", {
|
||||
sub: pushSubscription,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t("notification.toast.error.registerFailed"), {
|
||||
position: "top-center",
|
||||
});
|
||||
pushSubscription.unsubscribe();
|
||||
workerRegistration.unregister();
|
||||
setRegistration(null);
|
||||
});
|
||||
toast.success(t("notification.toast.success.registered"), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
},
|
||||
[addMessage, publicKey, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (watchCameras.length > 0) {
|
||||
form.setValue("allEnabled", false);
|
||||
}
|
||||
}, [watchCameras, allCameras, form]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("documentTitle.notifications");
|
||||
}, [t]);
|
||||
|
||||
if (formContext?.level && formContext.level !== "global") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
if (!("Notification" in window) || !window.isSecureContext) {
|
||||
return (
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="w-full max-w-5xl">
|
||||
<SettingsGroupCard
|
||||
title={t("notification.notificationSettings.title")}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>{t("notification.notificationSettings.desc")}</p>
|
||||
<div className="flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/notifications")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<CiCircleAlert className="size-5" />
|
||||
<AlertTitle>
|
||||
{t("notification.notificationUnavailable.title")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans ns="views/settings">
|
||||
notification.notificationUnavailable.desc
|
||||
</Trans>
|
||||
<div className="mt-3 flex items-center">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/authentication")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}{" "}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</SettingsGroupCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col md:flex-row">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
|
||||
<div className={cn("w-full max-w-5xl space-y-6")}>
|
||||
{isAdmin && (
|
||||
<SettingsGroupCard
|
||||
title={t("notification.notificationSettings.title")}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<Form {...form}>
|
||||
<div className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem className={SPLIT_ROW_CLASS_NAME}>
|
||||
<div className="space-y-1.5">
|
||||
<FormLabel htmlFor="notification-email">
|
||||
{t("notification.email.title")}
|
||||
</FormLabel>
|
||||
<FormDescription className="hidden md:block">
|
||||
{t("notification.email.desc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}
|
||||
>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="notification-email"
|
||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
|
||||
placeholder={t(
|
||||
"notification.email.placeholder",
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="md:hidden">
|
||||
{t("notification.email.desc")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cameras"
|
||||
render={({ field }) => (
|
||||
<FormItem className={SPLIT_ROW_CLASS_NAME}>
|
||||
<div className="space-y-1.5">
|
||||
<FormLabel className="text-base">
|
||||
{t("notification.cameras.title")}
|
||||
</FormLabel>
|
||||
<FormDescription className="hidden md:block">
|
||||
{t("notification.cameras.desc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}
|
||||
>
|
||||
{allCameras.length > 0 ? (
|
||||
<div className="w-full space-y-2 rounded-lg bg-secondary p-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allEnabled"
|
||||
render={({ field: allEnabledField }) => (
|
||||
<FilterSwitch
|
||||
label={t("cameras.all.title", {
|
||||
ns: "components/filter",
|
||||
})}
|
||||
isChecked={allEnabledField.value}
|
||||
onCheckedChange={(checked) => {
|
||||
setCameraSelectionTouched(true);
|
||||
if (checked) {
|
||||
form.setValue("cameras", []);
|
||||
}
|
||||
allEnabledField.onChange(checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{allCameras.map((camera) => {
|
||||
const currentCameras = Array.isArray(
|
||||
field.value,
|
||||
)
|
||||
? field.value
|
||||
: [];
|
||||
return (
|
||||
<FilterSwitch
|
||||
key={camera.name}
|
||||
label={camera.name}
|
||||
type="camera"
|
||||
isChecked={currentCameras.includes(
|
||||
camera.name,
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
setCameraSelectionTouched(true);
|
||||
const newCameras = checked
|
||||
? Array.from(
|
||||
new Set([
|
||||
...currentCameras,
|
||||
camera.name,
|
||||
]),
|
||||
)
|
||||
: currentCameras.filter(
|
||||
(value) => value !== camera.name,
|
||||
);
|
||||
field.onChange(newCameras);
|
||||
form.setValue("allEnabled", false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-normal text-destructive">
|
||||
{t("notification.cameras.noCameras")}
|
||||
</div>
|
||||
)}
|
||||
<FormDescription className="md:hidden">
|
||||
{t("notification.cameras.desc")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</SettingsGroupCard>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<SettingsGroupCard title={t("notification.deviceSpecific")}>
|
||||
<div className={cn("space-y-2", isAdmin && "md:max-w-[50%]")}>
|
||||
<Button
|
||||
aria-label={t("notification.registerDevice")}
|
||||
className="w-full md:w-auto"
|
||||
disabled={!shouldFetchPubKey || publicKey == undefined}
|
||||
onClick={() => {
|
||||
if (registration == null) {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
if (permission === "granted") {
|
||||
navigator.serviceWorker
|
||||
.register(NOTIFICATION_SERVICE_WORKER)
|
||||
.then((workerRegistration) => {
|
||||
setRegistration(workerRegistration);
|
||||
|
||||
if (workerRegistration.active) {
|
||||
subscribeToNotifications(workerRegistration);
|
||||
} else {
|
||||
setTimeout(
|
||||
() =>
|
||||
subscribeToNotifications(
|
||||
workerRegistration,
|
||||
),
|
||||
1000,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
registration.pushManager
|
||||
.getSubscription()
|
||||
.then((pushSubscription) => {
|
||||
pushSubscription?.unsubscribe();
|
||||
registration.unregister();
|
||||
setRegistration(null);
|
||||
removeMessage(
|
||||
"notification_settings",
|
||||
"registration",
|
||||
);
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{registration != null
|
||||
? t("notification.unregisterDevice")
|
||||
: t("notification.registerDevice")}
|
||||
</Button>
|
||||
{isAdmin && registration != null && registration.active && (
|
||||
<Button
|
||||
className="w-full md:w-auto"
|
||||
aria-label={t("notification.sendTestNotification")}
|
||||
onClick={() => sendTestNotification("notification_test")}
|
||||
>
|
||||
{t("notification.sendTestNotification")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingsGroupCard>
|
||||
|
||||
{isAdmin && notificationCameras.length > 0 && (
|
||||
<SettingsGroupCard title={t("notification.globalSettings.title")}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex max-w-xl flex-col gap-2 text-sm text-primary-variant">
|
||||
<p>{t("notification.globalSettings.desc")}</p>
|
||||
</div>
|
||||
<div className="w-full rounded-lg bg-secondary p-5 md:max-w-2xl">
|
||||
<div className="grid gap-6">
|
||||
{notificationCameras.map((item) => (
|
||||
<CameraNotificationSwitch
|
||||
key={item.name}
|
||||
config={config}
|
||||
camera={item.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsGroupCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CameraNotificationSwitchProps = {
|
||||
config?: FrigateConfig;
|
||||
camera: string;
|
||||
};
|
||||
|
||||
export function CameraNotificationSwitch({
|
||||
config,
|
||||
camera,
|
||||
}: CameraNotificationSwitchProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { payload: notificationState, send: sendNotification } =
|
||||
useNotifications(camera);
|
||||
const { payload: notificationSuspendUntil, send: sendNotificationSuspend } =
|
||||
useNotificationSuspend(camera);
|
||||
const [isSuspended, setIsSuspended] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (notificationSuspendUntil) {
|
||||
setIsSuspended(
|
||||
notificationSuspendUntil !== "0" || notificationState === "OFF",
|
||||
);
|
||||
}
|
||||
}, [notificationSuspendUntil, notificationState]);
|
||||
|
||||
const handleSuspend = (duration: string) => {
|
||||
setIsSuspended(true);
|
||||
if (duration == "off") {
|
||||
sendNotification("OFF");
|
||||
} else {
|
||||
sendNotificationSuspend(parseInt(duration));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelSuspension = () => {
|
||||
sendNotification("ON");
|
||||
sendNotificationSuspend(0);
|
||||
};
|
||||
|
||||
const locale = useDateLocale();
|
||||
|
||||
const formatSuspendedUntil = (timestamp: string) => {
|
||||
if (timestamp === "0") return t("time.untilForRestart", { ns: "common" });
|
||||
|
||||
const time = formatUnixTimestampToDateTime(parseInt(timestamp), {
|
||||
time_style: "medium",
|
||||
date_style: "medium",
|
||||
timezone: config?.ui.timezone,
|
||||
date_format:
|
||||
config?.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestampMonthDayHourMinute.24hour", {
|
||||
ns: "common",
|
||||
})
|
||||
: t("time.formattedTimestampMonthDayHourMinute.12hour", {
|
||||
ns: "common",
|
||||
}),
|
||||
locale: locale,
|
||||
});
|
||||
return t("time.untilForTime", { ns: "common", time });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="flex flex-row items-center justify-start gap-3">
|
||||
{!isSuspended ? (
|
||||
<LuCheck className="size-6 text-success" />
|
||||
) : (
|
||||
<LuX className="size-6 text-danger" />
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<CameraNameLabel
|
||||
className="text-md cursor-pointer text-primary smart-capitalize"
|
||||
htmlFor="camera"
|
||||
camera={camera}
|
||||
/>
|
||||
|
||||
{!isSuspended ? (
|
||||
<div className="flex flex-row items-center gap-2 text-sm text-success">
|
||||
{t("notification.active")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row items-center gap-2 text-sm text-danger">
|
||||
{t("notification.suspended", {
|
||||
time: formatSuspendedUntil(notificationSuspendUntil),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isSuspended ? (
|
||||
<Select onValueChange={handleSuspend}>
|
||||
<SelectTrigger className="w-auto">
|
||||
<SelectValue placeholder={t("notification.suspendTime.suspend")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">
|
||||
{t("notification.suspendTime.5minutes")}
|
||||
</SelectItem>
|
||||
<SelectItem value="10">
|
||||
{t("notification.suspendTime.10minutes")}
|
||||
</SelectItem>
|
||||
<SelectItem value="30">
|
||||
{t("notification.suspendTime.30minutes")}
|
||||
</SelectItem>
|
||||
<SelectItem value="60">
|
||||
{t("notification.suspendTime.1hour")}
|
||||
</SelectItem>
|
||||
<SelectItem value="840">
|
||||
{t("notification.suspendTime.12hours")}
|
||||
</SelectItem>
|
||||
<SelectItem value="1440">
|
||||
{t("notification.suspendTime.24hours")}
|
||||
</SelectItem>
|
||||
<SelectItem value="off">
|
||||
{t("notification.suspendTime.untilRestart")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleCancelSuspension}
|
||||
>
|
||||
{t("notification.cancelSuspension")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ComponentType } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import get from "lodash/get";
|
||||
import set from "lodash/set";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import { TagsWidget } from "@/components/config-form/theme/widgets/TagsWidget";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
|
||||
import type { SectionRendererProps } from "./registry";
|
||||
|
||||
const EMPTY_FORM_DATA: JsonObject = {};
|
||||
const RoleMapTags = TagsWidget as unknown as ComponentType<{
|
||||
id: string;
|
||||
value: string[];
|
||||
onChange: (value: unknown) => void;
|
||||
schema: { title: string };
|
||||
}>;
|
||||
|
||||
export default function ProxyRoleMap({ formContext }: SectionRendererProps) {
|
||||
const { t } = useTranslation(["views/settings", "config/global"]);
|
||||
|
||||
const fullFormData =
|
||||
(formContext?.formData as JsonObject | undefined) ?? EMPTY_FORM_DATA;
|
||||
const onFormDataChange = formContext?.onFormDataChange;
|
||||
|
||||
const roleHeader = get(fullFormData, "header_map.role");
|
||||
const hasRoleHeader =
|
||||
typeof roleHeader === "string" && roleHeader.trim().length > 0;
|
||||
|
||||
const roleMap = useMemo(() => {
|
||||
const roleMapValue = get(fullFormData, "header_map.role_map");
|
||||
return isJsonObject(roleMapValue)
|
||||
? (roleMapValue as Record<string, string[]>)
|
||||
: {};
|
||||
}, [fullFormData]);
|
||||
|
||||
const roleOptions = useMemo(() => {
|
||||
const rolesFromConfig = formContext?.fullConfig?.auth?.roles
|
||||
? Object.keys(formContext.fullConfig.auth.roles)
|
||||
: [];
|
||||
const roles =
|
||||
rolesFromConfig.length > 0 ? rolesFromConfig : ["admin", "viewer"];
|
||||
|
||||
return Array.from(new Set([...roles, ...Object.keys(roleMap)])).sort();
|
||||
}, [formContext?.fullConfig, roleMap]);
|
||||
|
||||
if (!onFormDataChange || !formContext?.formData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasRoleHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usedRoles = new Set(Object.keys(roleMap));
|
||||
const nextRole = roleOptions.find((role) => !usedRoles.has(role));
|
||||
|
||||
const updateRoleMap = (nextRoleMap: Record<string, string[]>) => {
|
||||
const nextFormData = cloneDeep(fullFormData) as JsonObject;
|
||||
set(nextFormData, "header_map.role_map", nextRoleMap);
|
||||
onFormDataChange(nextFormData as ConfigSectionData);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!nextRole) return;
|
||||
updateRoleMap({
|
||||
...roleMap,
|
||||
[nextRole]: [],
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemove = (role: string) => {
|
||||
const next = { ...roleMap };
|
||||
delete next[role];
|
||||
updateRoleMap(next);
|
||||
};
|
||||
|
||||
const handleRoleChange = (currentRole: string, newRole: string) => {
|
||||
if (currentRole === newRole) return;
|
||||
const next = { ...roleMap } as Record<string, string[]>;
|
||||
const groups = next[currentRole] ?? [];
|
||||
delete next[currentRole];
|
||||
next[newRole] = groups;
|
||||
updateRoleMap(next);
|
||||
};
|
||||
|
||||
const handleGroupsChange = (role: string, groups: unknown) => {
|
||||
updateRoleMap({
|
||||
...roleMap,
|
||||
[role]: Array.isArray(groups) ? groups : [],
|
||||
});
|
||||
};
|
||||
|
||||
const roleMapLabel = t("proxy.header_map.role_map.label", {
|
||||
ns: "config/global",
|
||||
});
|
||||
const roleMapDescription = t("proxy.header_map.role_map.description", {
|
||||
ns: "config/global",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm font-medium">{roleMapLabel}</Label>
|
||||
<p className="text-xs text-muted-foreground">{roleMapDescription}</p>
|
||||
</div>
|
||||
|
||||
{Object.keys(roleMap).length === 0 && (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
{t("configForm.roleMap.empty", { ns: "views/settings" })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{Object.entries(roleMap).map(([role, groups], index) => {
|
||||
const rowId = `role-map-${role}-${index}`;
|
||||
const roleLabel = t("configForm.roleMap.roleLabel", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const groupsLabel = t("configForm.roleMap.groupsLabel", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const normalizedGroups = Array.isArray(groups) ? groups : [];
|
||||
|
||||
return (
|
||||
<div key={rowId} className="grid grid-cols-12 items-start gap-2">
|
||||
<div className="col-span-12 space-y-2 md:col-span-4">
|
||||
<Label htmlFor={`${rowId}-role`}>{roleLabel}</Label>
|
||||
<Select
|
||||
value={role}
|
||||
onValueChange={(next) => handleRoleChange(role, next)}
|
||||
>
|
||||
<SelectTrigger id={`${rowId}-role`} className="w-full">
|
||||
<SelectValue placeholder={roleLabel} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roleOptions.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 space-y-2 md:col-span-7">
|
||||
<Label htmlFor={`${rowId}-groups`}>{groupsLabel}</Label>
|
||||
<RoleMapTags
|
||||
id={`${rowId}-groups`}
|
||||
value={normalizedGroups}
|
||||
onChange={(next) => handleGroupsChange(role, next)}
|
||||
schema={{ title: groupsLabel }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 flex items-center md:col-span-1 md:justify-center md:pt-7">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemove(role)}
|
||||
aria-label={t("configForm.roleMap.remove", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
title={t("configForm.roleMap.remove", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
disabled={!nextRole}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("configForm.roleMap.addMapping", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from "react";
|
||||
import axios from "axios";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
export default function SemanticSearchReindex() {
|
||||
const { t } = useTranslation("views/settings");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
const onReindex = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await axios.put("/reindex");
|
||||
if (res.status === 202) {
|
||||
toast.success(t("enrichments.semanticSearch.reindexNow.success"), {
|
||||
position: "top-center",
|
||||
});
|
||||
} else {
|
||||
toast.error(
|
||||
t("enrichments.semanticSearch.reindexNow.error", {
|
||||
errorMessage: res.statusText,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
}
|
||||
} catch (caught) {
|
||||
const error = caught as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
};
|
||||
const errorMessage =
|
||||
error.response?.data?.message || error.response?.data?.detail || "";
|
||||
toast.error(
|
||||
t("enrichments.semanticSearch.reindexNow.error", {
|
||||
errorMessage: errorMessage || undefined,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="flex">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
disabled={isLoading}
|
||||
aria-label={t("enrichments.semanticSearch.reindexNow.label")}
|
||||
>
|
||||
{t("enrichments.semanticSearch.reindexNow.label")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
<Trans ns="views/settings">
|
||||
enrichments.semanticSearch.reindexNow.desc
|
||||
</Trans>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("enrichments.semanticSearch.reindexNow.confirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<Trans ns="views/settings">
|
||||
enrichments.semanticSearch.reindexNow.confirmDesc
|
||||
</Trans>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setIsDialogOpen(false)}>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "select" })}
|
||||
onClick={async () => {
|
||||
await onReindex();
|
||||
setIsDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("enrichments.semanticSearch.reindexNow.confirmButton")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ComponentType } from "react";
|
||||
import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
|
||||
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
|
||||
import ProxyRoleMap from "./ProxyRoleMap";
|
||||
import NotificationsSettingsExtras from "./NotificationsSettingsExtras";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
|
||||
// Props that will be injected into all section renderers
|
||||
export type SectionRendererProps = {
|
||||
selectedCamera?: string;
|
||||
setUnsavedChanges?: (hasChanges: boolean) => void;
|
||||
formContext?: ConfigFormContext;
|
||||
[key: string]: unknown; // Allow additional props from uiSchema
|
||||
};
|
||||
|
||||
export type RendererComponent = ComponentType<SectionRendererProps>;
|
||||
|
||||
export type SectionRenderers = Record<
|
||||
string,
|
||||
Record<string, RendererComponent>
|
||||
>;
|
||||
|
||||
// Section renderers registry
|
||||
// Used to register custom renderer components for specific config sections.
|
||||
// Maps a section key (e.g., `semantic_search`) to a mapping of renderer
|
||||
// names to React components. These names are referenced from `uiSchema`
|
||||
// descriptors (e.g., `{ "ui:after": { render: "SemanticSearchReindex" } }`) and
|
||||
// are resolved by `FieldTemplate` through `formContext.renderers`.
|
||||
//
|
||||
// RUNTIME PROPS INJECTION:
|
||||
// All renderers automatically receive the following props from BaseSection:
|
||||
// - selectedCamera?: string - The current camera name (camera-level only)
|
||||
// - setUnsavedChanges?: (hasChanges: boolean) => void - Callback to signal unsaved state
|
||||
//
|
||||
// Additional static props can be passed via uiSchema:
|
||||
// { "ui:after": { render: "MyRenderer", props: { customProp: "value" } } }
|
||||
//
|
||||
// ADDING NEW RENDERERS:
|
||||
// 1. Create your component accepting SectionRendererProps
|
||||
// 2. Import and add it to the appropriate section in this registry
|
||||
// 3. Reference it in your section's uiSchema using the { render: "ComponentName" } syntax
|
||||
export const sectionRenderers: SectionRenderers = {
|
||||
semantic_search: {
|
||||
SemanticSearchReindex,
|
||||
},
|
||||
review: {
|
||||
CameraReviewStatusToggles,
|
||||
},
|
||||
proxy: {
|
||||
ProxyRoleMap,
|
||||
},
|
||||
notifications: {
|
||||
NotificationsSettingsExtras,
|
||||
},
|
||||
};
|
||||
|
||||
export default sectionRenderers;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from "react";
|
||||
import { ConfigSection } from "./BaseSection";
|
||||
import type { BaseSectionProps, SectionConfig } from "./BaseSection";
|
||||
import { getSectionConfig } from "@/utils/configUtil";
|
||||
|
||||
export type ConfigSectionTemplateProps = BaseSectionProps & {
|
||||
sectionKey: string;
|
||||
sectionConfig?: SectionConfig;
|
||||
};
|
||||
|
||||
export function ConfigSectionTemplate({
|
||||
sectionKey,
|
||||
level,
|
||||
sectionConfig,
|
||||
...rest
|
||||
}: ConfigSectionTemplateProps) {
|
||||
const defaultConfig = useMemo(
|
||||
() => getSectionConfig(sectionKey, level),
|
||||
[sectionKey, level],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigSection
|
||||
sectionPath={sectionKey}
|
||||
defaultConfig={defaultConfig}
|
||||
level={level}
|
||||
sectionConfig={sectionConfig ?? defaultConfig}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigSectionTemplate;
|
||||
@@ -0,0 +1,14 @@
|
||||
// Config Form Section Components
|
||||
// Reusable components for both global and camera-level settings
|
||||
|
||||
export {
|
||||
ConfigSection,
|
||||
type BaseSectionProps,
|
||||
type SectionConfig,
|
||||
type CreateSectionOptions,
|
||||
type ConfigSectionProps,
|
||||
} from "./BaseSection";
|
||||
export {
|
||||
ConfigSectionTemplate,
|
||||
type ConfigSectionTemplateProps,
|
||||
} from "./ConfigSectionTemplate";
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Special case handling for config sections with schema/default issues.
|
||||
*
|
||||
* Some sections have schema patterns that cause false "Modified" indicators
|
||||
* when navigating to them due to how defaults are applied. This utility
|
||||
* centralizes the logic for detecting and handling these cases.
|
||||
*/
|
||||
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import { JsonObject, JsonValue } from "@/types/configForm";
|
||||
|
||||
/**
|
||||
* Sections that require special handling at the global level.
|
||||
* Add new section paths here as needed.
|
||||
*/
|
||||
const SPECIAL_CASE_SECTIONS = ["motion", "detectors"] as const;
|
||||
|
||||
/**
|
||||
* Check if a section requires special case handling.
|
||||
*/
|
||||
export function isSpecialCaseSection(
|
||||
sectionPath: string,
|
||||
level: string,
|
||||
): boolean {
|
||||
return (
|
||||
level === "global" &&
|
||||
SPECIAL_CASE_SECTIONS.includes(
|
||||
sectionPath as (typeof SPECIAL_CASE_SECTIONS)[number],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify schema for sections that need defaults stripped or other modifications.
|
||||
*
|
||||
* - detectors: Strip the "default" field to prevent RJSF from merging the
|
||||
* default {"cpu": {"type": "cpu"}} with stored detector keys.
|
||||
*/
|
||||
export function modifySchemaForSection(
|
||||
sectionPath: string,
|
||||
level: string,
|
||||
schema: RJSFSchema | undefined,
|
||||
): RJSFSchema | undefined {
|
||||
if (!schema || !isSpecialCaseSection(sectionPath, level)) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
// detectors: Remove default to prevent merging with stored keys
|
||||
if (sectionPath === "detectors" && "default" in schema) {
|
||||
const { default: _, ...schemaWithoutDefault } = schema;
|
||||
return schemaWithoutDefault;
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective defaults for sections with special schema patterns.
|
||||
*
|
||||
* - motion: Has anyOf schema with [null, MotionConfig]. When stored value is
|
||||
* null, derive defaults from the non-null anyOf branch to avoid showing
|
||||
* changes when navigating to the page.
|
||||
* - detectors: Return empty object since the schema default would add unwanted
|
||||
* keys to the stored configuration.
|
||||
*/
|
||||
export function getEffectiveDefaultsForSection(
|
||||
sectionPath: string,
|
||||
level: string,
|
||||
schema: RJSFSchema | undefined,
|
||||
schemaDefaults: unknown,
|
||||
): unknown {
|
||||
if (!isSpecialCaseSection(sectionPath, level) || !schema) {
|
||||
return schemaDefaults;
|
||||
}
|
||||
|
||||
// motion: Derive defaults from non-null anyOf branch
|
||||
if (sectionPath === "motion") {
|
||||
const anyOfSchemas = (schema as { anyOf?: unknown[] }).anyOf;
|
||||
if (!anyOfSchemas || !Array.isArray(anyOfSchemas)) {
|
||||
return schemaDefaults;
|
||||
}
|
||||
|
||||
// Find the non-null motion config schema
|
||||
const motionSchema = anyOfSchemas.find(
|
||||
(s) =>
|
||||
typeof s === "object" &&
|
||||
s !== null &&
|
||||
(s as { type?: string }).type !== "null",
|
||||
);
|
||||
|
||||
if (!motionSchema) {
|
||||
return schemaDefaults;
|
||||
}
|
||||
|
||||
return applySchemaDefaults(motionSchema as RJSFSchema, {});
|
||||
}
|
||||
|
||||
// detectors: Return empty object to avoid adding default keys
|
||||
if (sectionPath === "detectors") {
|
||||
return {};
|
||||
}
|
||||
|
||||
return schemaDefaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize overrides payloads for section-specific quirks.
|
||||
*/
|
||||
export function sanitizeOverridesForSection(
|
||||
sectionPath: string,
|
||||
level: string,
|
||||
overrides: unknown,
|
||||
): unknown {
|
||||
if (!overrides || !isJsonObject(overrides)) {
|
||||
return overrides;
|
||||
}
|
||||
|
||||
if (sectionPath === "ffmpeg" && level === "camera") {
|
||||
const overridesObj = overrides as JsonObject;
|
||||
const inputs = overridesObj.inputs;
|
||||
if (!Array.isArray(inputs)) {
|
||||
return overrides;
|
||||
}
|
||||
|
||||
const cleanedInputs = inputs.map((input) => {
|
||||
if (!isJsonObject(input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const cleanedInput = { ...input } as JsonObject;
|
||||
["global_args", "hwaccel_args", "input_args"].forEach((key) => {
|
||||
const value = cleanedInput[key];
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
delete cleanedInput[key];
|
||||
}
|
||||
});
|
||||
|
||||
return cleanedInput;
|
||||
});
|
||||
|
||||
return {
|
||||
...overridesObj,
|
||||
inputs: cleanedInputs,
|
||||
};
|
||||
}
|
||||
|
||||
const flattenRecordWithDots = (
|
||||
value: JsonObject,
|
||||
prefix: string = "",
|
||||
): JsonObject => {
|
||||
const flattened: JsonObject = {};
|
||||
Object.entries(value).forEach(([key, entry]) => {
|
||||
const nextKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (isJsonObject(entry)) {
|
||||
Object.assign(flattened, flattenRecordWithDots(entry, nextKey));
|
||||
} else {
|
||||
flattened[nextKey] = entry as JsonValue;
|
||||
}
|
||||
});
|
||||
return flattened;
|
||||
};
|
||||
|
||||
// detectors: Strip readonly model fields that are generated on startup
|
||||
// and should never be persisted back to the config file.
|
||||
if (sectionPath === "detectors") {
|
||||
const overridesObj = overrides as JsonObject;
|
||||
const cleaned: JsonObject = {};
|
||||
|
||||
Object.entries(overridesObj).forEach(([key, value]) => {
|
||||
if (!isJsonObject(value)) {
|
||||
cleaned[key] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanedValue = { ...value } as JsonObject;
|
||||
delete cleanedValue.model;
|
||||
delete cleanedValue.model_path;
|
||||
cleaned[key] = cleanedValue;
|
||||
});
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
if (sectionPath === "logger") {
|
||||
const overridesObj = overrides as JsonObject;
|
||||
const logs = overridesObj.logs;
|
||||
if (isJsonObject(logs)) {
|
||||
return {
|
||||
...overridesObj,
|
||||
logs: flattenRecordWithDots(logs),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (sectionPath === "environment_vars") {
|
||||
const overridesObj = overrides as JsonObject;
|
||||
return flattenRecordWithDots(overridesObj);
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Shared UI components for config form templates and fields.
|
||||
*/
|
||||
|
||||
import { canExpand } from "@rjsf/utils";
|
||||
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LuPlus, LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface AddPropertyButtonProps {
|
||||
/** Callback fired when the add button is clicked */
|
||||
onAddProperty?: () => void;
|
||||
/** JSON Schema to determine expandability */
|
||||
schema: RJSFSchema;
|
||||
/** UI Schema for expansion checks */
|
||||
uiSchema?: UiSchema;
|
||||
/** Current form data for expansion checks */
|
||||
formData?: unknown;
|
||||
/** Whether the form is disabled */
|
||||
disabled?: boolean;
|
||||
/** Whether the form is read-only */
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add property button for RJSF objects with additionalProperties.
|
||||
* Shows "Add" button that allows adding new key-value pairs to objects.
|
||||
*/
|
||||
export function AddPropertyButton({
|
||||
onAddProperty,
|
||||
schema,
|
||||
uiSchema,
|
||||
formData,
|
||||
disabled,
|
||||
readonly,
|
||||
}: AddPropertyButtonProps) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
const canAdd =
|
||||
Boolean(onAddProperty) && canExpand(schema, uiSchema, formData);
|
||||
|
||||
if (!canAdd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAddProperty}
|
||||
disabled={disabled || readonly}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("button.add", { ns: "common", defaultValue: "Add" })}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdvancedCollapsibleProps {
|
||||
/** Number of advanced fields */
|
||||
count: number;
|
||||
/** Whether the collapsible is open */
|
||||
open: boolean;
|
||||
/** Callback when open state changes */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Content to show when expanded */
|
||||
children: ReactNode;
|
||||
/** Use root-level label variant (longer text) */
|
||||
isRoot?: boolean;
|
||||
/** Button size - defaults to undefined (default) for root, "sm" for nested */
|
||||
buttonSize?: "sm" | "default" | "lg" | "icon";
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible section for advanced form fields.
|
||||
* Provides consistent styling and i18n labels for advanced settings.
|
||||
*/
|
||||
export function AdvancedCollapsible({
|
||||
count,
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
isRoot = false,
|
||||
buttonSize,
|
||||
}: AdvancedCollapsibleProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
|
||||
if (count === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveSize = buttonSize ?? (isRoot ? undefined : "sm");
|
||||
|
||||
const label = isRoot
|
||||
? t("configForm.advancedSettingsCount", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Advanced Settings ({{count}})",
|
||||
count,
|
||||
})
|
||||
: t("configForm.advancedCount", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Advanced ({{count}})",
|
||||
count,
|
||||
});
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={onOpenChange}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={effectiveSize}
|
||||
className="w-full justify-start gap-2 pl-0 hover:bg-transparent"
|
||||
>
|
||||
{open ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-4 rounded-lg border border-border/60 bg-secondary/40 p-4">
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import type {
|
||||
ErrorSchema,
|
||||
FieldProps,
|
||||
RJSFSchema,
|
||||
UiSchema,
|
||||
} from "@rjsf/utils";
|
||||
import { toFieldPathId } from "@rjsf/utils";
|
||||
import { cloneDeep, isEqual } from "lodash";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { mergeUiSchema } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
LuPlus,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
type FfmpegInput = {
|
||||
path?: string;
|
||||
roles?: string[];
|
||||
hwaccel_args?: unknown;
|
||||
};
|
||||
|
||||
const asInputList = (formData: unknown): FfmpegInput[] => {
|
||||
if (!Array.isArray(formData)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return formData.filter(
|
||||
(item): item is FfmpegInput => typeof item === "object" && item !== null,
|
||||
);
|
||||
};
|
||||
|
||||
const getItemSchema = (schema: RJSFSchema): RJSFSchema | undefined => {
|
||||
const items = schema.items;
|
||||
if (!items || typeof items !== "object" || Array.isArray(items)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return items as RJSFSchema;
|
||||
};
|
||||
|
||||
const getItemProperties = (
|
||||
schema: RJSFSchema | undefined,
|
||||
): Record<string, RJSFSchema> => {
|
||||
if (!schema || typeof schema.properties !== "object" || !schema.properties) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return schema.properties as Record<string, RJSFSchema>;
|
||||
};
|
||||
|
||||
const hasDetectRole = (input: FfmpegInput): boolean =>
|
||||
Array.isArray(input.roles) && input.roles.includes("detect");
|
||||
|
||||
const hasHwaccelValue = (value: unknown): boolean => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const normalizeNonDetectHwaccel = (inputs: FfmpegInput[]): FfmpegInput[] =>
|
||||
inputs.map((input) => {
|
||||
if (hasDetectRole(input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (!hasHwaccelValue(input.hwaccel_args)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return {
|
||||
...input,
|
||||
hwaccel_args: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
export function CameraInputsField(props: FieldProps) {
|
||||
const {
|
||||
schema,
|
||||
uiSchema,
|
||||
formData,
|
||||
onChange,
|
||||
fieldPathId,
|
||||
registry,
|
||||
idSchema,
|
||||
errorSchema,
|
||||
disabled,
|
||||
readonly,
|
||||
hideError,
|
||||
onBlur,
|
||||
onFocus,
|
||||
} = props;
|
||||
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const effectiveNamespace = isCameraLevel ? "config/cameras" : "config/global";
|
||||
|
||||
const { t, i18n } = useTranslation([
|
||||
"common",
|
||||
"views/settings",
|
||||
effectiveNamespace,
|
||||
]);
|
||||
|
||||
const inputs = useMemo(() => asInputList(formData), [formData]);
|
||||
const arraySchema = schema as RJSFSchema;
|
||||
const itemSchema = useMemo(() => getItemSchema(arraySchema), [arraySchema]);
|
||||
const itemProperties = useMemo(
|
||||
() => getItemProperties(itemSchema),
|
||||
[itemSchema],
|
||||
);
|
||||
const itemUiSchema = useMemo(
|
||||
() =>
|
||||
((uiSchema as { items?: UiSchema } | undefined)?.items ?? {}) as UiSchema,
|
||||
[uiSchema],
|
||||
);
|
||||
const SchemaField = registry.fields.SchemaField;
|
||||
|
||||
const [openByIndex, setOpenByIndex] = useState<Record<number, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setOpenByIndex((previous) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
for (let index = 0; index < inputs.length; index += 1) {
|
||||
next[index] = previous[index] ?? true;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [inputs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = normalizeNonDetectHwaccel(inputs);
|
||||
if (!isEqual(normalized, inputs)) {
|
||||
onChange(normalized, fieldPathId.path);
|
||||
}
|
||||
}, [fieldPathId.path, inputs, onChange]);
|
||||
|
||||
const handleFieldValueChange = useCallback(
|
||||
(index: number, fieldName: string, nextValue: unknown) => {
|
||||
const nextInputs = cloneDeep(inputs);
|
||||
const item =
|
||||
(nextInputs[index] as Record<string, unknown> | undefined) ??
|
||||
({} as Record<string, unknown>);
|
||||
|
||||
item[fieldName] = nextValue;
|
||||
nextInputs[index] = item;
|
||||
|
||||
onChange(normalizeNonDetectHwaccel(nextInputs), fieldPathId.path);
|
||||
},
|
||||
[fieldPathId.path, inputs, onChange],
|
||||
);
|
||||
|
||||
const handleAddInput = useCallback(() => {
|
||||
const base = itemSchema
|
||||
? (applySchemaDefaults(itemSchema) as FfmpegInput)
|
||||
: ({} as FfmpegInput);
|
||||
const nextInputs = normalizeNonDetectHwaccel([...inputs, base]);
|
||||
onChange(nextInputs, fieldPathId.path);
|
||||
setOpenByIndex((previous) => ({ ...previous, [inputs.length]: true }));
|
||||
}, [fieldPathId.path, inputs, itemSchema, onChange]);
|
||||
|
||||
const handleRemoveInput = useCallback(
|
||||
(index: number) => {
|
||||
const nextInputs = inputs.filter(
|
||||
(_, currentIndex) => currentIndex !== index,
|
||||
);
|
||||
onChange(nextInputs, fieldPathId.path);
|
||||
setOpenByIndex((previous) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
const current = Number(key);
|
||||
if (Number.isNaN(current) || current === index) {
|
||||
return;
|
||||
}
|
||||
|
||||
next[current > index ? current - 1 : current] = value;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[fieldPathId.path, inputs, onChange],
|
||||
);
|
||||
|
||||
const renderField = useCallback(
|
||||
(
|
||||
index: number,
|
||||
fieldName: string,
|
||||
options?: {
|
||||
extraUiSchema?: UiSchema;
|
||||
showSchemaDescription?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (!SchemaField) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldSchema = itemProperties[fieldName];
|
||||
if (!fieldSchema) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const itemData = inputs[index] as Record<string, unknown>;
|
||||
const itemPath = [...fieldPathId.path, index];
|
||||
const itemFieldPathId = toFieldPathId(
|
||||
fieldName,
|
||||
registry.globalFormOptions,
|
||||
itemPath,
|
||||
);
|
||||
|
||||
const itemErrors = (
|
||||
errorSchema as Record<string, ErrorSchema> | undefined
|
||||
)?.[index] as Record<string, ErrorSchema> | undefined;
|
||||
const fieldErrorSchema = itemErrors?.[fieldName];
|
||||
|
||||
const baseUiSchema =
|
||||
(itemUiSchema[fieldName] as UiSchema | undefined) ?? ({} as UiSchema);
|
||||
const mergedUiSchema = options?.extraUiSchema
|
||||
? mergeUiSchema(baseUiSchema, options.extraUiSchema)
|
||||
: baseUiSchema;
|
||||
|
||||
const fieldTranslationDescriptionKey = `ffmpeg.inputs.${fieldName}.description`;
|
||||
const translatedDescription = i18n.exists(
|
||||
fieldTranslationDescriptionKey,
|
||||
{
|
||||
ns: effectiveNamespace,
|
||||
},
|
||||
)
|
||||
? t(fieldTranslationDescriptionKey, { ns: effectiveNamespace })
|
||||
: "";
|
||||
|
||||
const fieldDescription =
|
||||
typeof fieldSchema.description === "string" &&
|
||||
fieldSchema.description.length > 0
|
||||
? fieldSchema.description
|
||||
: translatedDescription;
|
||||
|
||||
const handleScopedFieldChange = (
|
||||
nextValue: unknown,
|
||||
_path: unknown,
|
||||
_errors?: ErrorSchema,
|
||||
_id?: string,
|
||||
) => {
|
||||
handleFieldValueChange(index, fieldName, nextValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<SchemaField
|
||||
name={fieldName}
|
||||
schema={fieldSchema}
|
||||
uiSchema={mergedUiSchema}
|
||||
fieldPathId={itemFieldPathId}
|
||||
formData={itemData?.[fieldName]}
|
||||
errorSchema={fieldErrorSchema}
|
||||
onChange={handleScopedFieldChange}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
registry={registry}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
hideError={hideError}
|
||||
/>
|
||||
{options?.showSchemaDescription && fieldDescription ? (
|
||||
<p className="text-xs text-muted-foreground">{fieldDescription}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
SchemaField,
|
||||
itemProperties,
|
||||
inputs,
|
||||
fieldPathId.path,
|
||||
registry,
|
||||
errorSchema,
|
||||
itemUiSchema,
|
||||
i18n,
|
||||
handleFieldValueChange,
|
||||
effectiveNamespace,
|
||||
onBlur,
|
||||
onFocus,
|
||||
disabled,
|
||||
readonly,
|
||||
hideError,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const baseId = idSchema?.$id ?? "ffmpeg_inputs";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{inputs.map((input, index) => {
|
||||
const open = openByIndex[index] ?? true;
|
||||
const itemTitle = t("configForm.cameraInputs.itemTitle", {
|
||||
ns: "views/settings",
|
||||
index: index + 1,
|
||||
});
|
||||
const itemPath =
|
||||
typeof input.path === "string" ? input.path.trim() : "";
|
||||
|
||||
return (
|
||||
<Card key={`${baseId}-${index}`} className="w-full">
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) =>
|
||||
setOpenByIndex((previous) => ({
|
||||
...previous,
|
||||
[index]: nextOpen,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<CardTitle className="text-sm">
|
||||
<span>{itemTitle}</span>
|
||||
{itemPath ? (
|
||||
<span className="mt-1 block text-xs font-normal text-muted-foreground">
|
||||
{itemPath}
|
||||
</span>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
{open ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-4 p-4 pt-0">
|
||||
<div className="w-full">
|
||||
{renderField(index, "path", {
|
||||
extraUiSchema: {
|
||||
"ui:widget": "CameraPathWidget",
|
||||
"ui:options": {
|
||||
size: "full",
|
||||
splitLayout: false,
|
||||
},
|
||||
},
|
||||
showSchemaDescription: true,
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="w-full">{renderField(index, "roles")}</div>
|
||||
|
||||
{renderField(index, "input_args")}
|
||||
|
||||
{hasDetectRole(input)
|
||||
? renderField(index, "hwaccel_args", {
|
||||
extraUiSchema: {
|
||||
"ui:options": {
|
||||
allowInherit: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null}
|
||||
|
||||
{renderField(index, "output_args")}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveInput(index)}
|
||||
disabled={disabled || readonly}
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddInput}
|
||||
disabled={disabled || readonly}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("button.add", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CameraInputsField;
|
||||
@@ -0,0 +1,891 @@
|
||||
import type {
|
||||
ErrorSchema,
|
||||
FieldPathList,
|
||||
FieldProps,
|
||||
RJSFSchema,
|
||||
UiSchema,
|
||||
} from "@rjsf/utils";
|
||||
import { toFieldPathId } from "@rjsf/utils";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
LuPlus,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { cn, isJsonObject, mergeUiSchema } from "@/lib/utils";
|
||||
import { ConfigFormContext, JsonObject } from "@/types/configForm";
|
||||
import { requiresRestartForFieldPath } from "@/utils/configUtil";
|
||||
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { humanizeKey } from "../utils/i18n";
|
||||
|
||||
type DetectorHardwareFieldOptions = {
|
||||
multiInstanceTypes?: string[];
|
||||
hiddenByType?: Record<string, string[]>;
|
||||
hiddenFields?: string[];
|
||||
typeOrder?: string[];
|
||||
};
|
||||
|
||||
type DetectorSchemaEntry = {
|
||||
type: string;
|
||||
schema: RJSFSchema;
|
||||
};
|
||||
|
||||
const DEFAULT_MULTI_INSTANCE_TYPES = ["cpu", "onnx", "openvino"];
|
||||
const EMPTY_HIDDEN_BY_TYPE: Record<string, string[]> = {};
|
||||
const EMPTY_HIDDEN_FIELDS: string[] = [];
|
||||
const EMPTY_TYPE_ORDER: string[] = [];
|
||||
|
||||
const isSchemaObject = (schema: unknown): schema is RJSFSchema =>
|
||||
typeof schema === "object" && schema !== null;
|
||||
|
||||
const getUnionSchemas = (schema?: RJSFSchema): RJSFSchema[] => {
|
||||
if (!schema) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const schemaObj = schema as Record<string, unknown>;
|
||||
const union = schemaObj.oneOf ?? schemaObj.anyOf;
|
||||
if (Array.isArray(union)) {
|
||||
return union.filter(isSchemaObject) as RJSFSchema[];
|
||||
}
|
||||
|
||||
return [schema];
|
||||
};
|
||||
|
||||
const getTypeValues = (schema: RJSFSchema): string[] => {
|
||||
const schemaObj = schema as Record<string, unknown>;
|
||||
const properties = schemaObj.properties as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const typeSchema = properties?.type as Record<string, unknown> | undefined;
|
||||
const values: string[] = [];
|
||||
|
||||
if (typeof typeSchema?.const === "string") {
|
||||
values.push(typeSchema.const);
|
||||
}
|
||||
|
||||
if (Array.isArray(typeSchema?.enum)) {
|
||||
typeSchema.enum.forEach((value) => {
|
||||
if (typeof value === "string") {
|
||||
values.push(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return values;
|
||||
};
|
||||
|
||||
const buildHiddenUiSchema = (paths: string[]): UiSchema => {
|
||||
const result: UiSchema = {};
|
||||
|
||||
paths.forEach((path) => {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const segments = path.split(".").filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cursor = result;
|
||||
segments.forEach((segment, index) => {
|
||||
if (index === segments.length - 1) {
|
||||
cursor[segment] = {
|
||||
...(cursor[segment] as UiSchema | undefined),
|
||||
"ui:widget": "hidden",
|
||||
} as UiSchema;
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = (cursor[segment] as UiSchema | undefined) ?? {};
|
||||
cursor[segment] = existing;
|
||||
cursor = existing;
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const getInstanceType = (value: unknown): string | undefined => {
|
||||
if (!isJsonObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const typeValue = value.type;
|
||||
return typeof typeValue === "string" && typeValue.length > 0
|
||||
? typeValue
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export function DetectorHardwareField(props: FieldProps) {
|
||||
const {
|
||||
schema,
|
||||
uiSchema,
|
||||
registry,
|
||||
fieldPathId,
|
||||
formData: rawFormData,
|
||||
errorSchema,
|
||||
disabled,
|
||||
readonly,
|
||||
hideError,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onChange,
|
||||
} = props;
|
||||
|
||||
const formContext = registry.formContext as ConfigFormContext | undefined;
|
||||
const configNamespace =
|
||||
formContext?.i18nNamespace ??
|
||||
(formContext?.level === "camera" ? "config/cameras" : "config/global");
|
||||
const { t: fallbackT } = useTranslation(["common", configNamespace]);
|
||||
const t = formContext?.t ?? fallbackT;
|
||||
const sectionPrefix = formContext?.sectionI18nPrefix ?? "detectors";
|
||||
const restartRequired = formContext?.restartRequired;
|
||||
const defaultRequiresRestart = formContext?.requiresRestart ?? true;
|
||||
|
||||
const options =
|
||||
(uiSchema?.["ui:options"] as DetectorHardwareFieldOptions | undefined) ??
|
||||
{};
|
||||
const multiInstanceTypes =
|
||||
options.multiInstanceTypes ?? DEFAULT_MULTI_INSTANCE_TYPES;
|
||||
const hiddenByType = options.hiddenByType ?? EMPTY_HIDDEN_BY_TYPE;
|
||||
const hiddenFields = options.hiddenFields ?? EMPTY_HIDDEN_FIELDS;
|
||||
const typeOrder = options.typeOrder ?? EMPTY_TYPE_ORDER;
|
||||
const multiInstanceSet = useMemo(
|
||||
() => new Set(multiInstanceTypes),
|
||||
[multiInstanceTypes],
|
||||
);
|
||||
const globalHiddenFields = useMemo(
|
||||
() =>
|
||||
hiddenFields
|
||||
.map((path) => (path.startsWith("*.") ? path.slice(2) : path))
|
||||
.filter((path) => path.length > 0),
|
||||
[hiddenFields],
|
||||
);
|
||||
|
||||
const detectorConfigSchema = useMemo(() => {
|
||||
const additional = (schema as RJSFSchema | undefined)?.additionalProperties;
|
||||
if (isSchemaObject(additional)) {
|
||||
return additional as RJSFSchema;
|
||||
}
|
||||
|
||||
const rootSchema = registry.rootSchema as Record<string, unknown>;
|
||||
const defs =
|
||||
(rootSchema?.$defs as Record<string, unknown> | undefined) ??
|
||||
(rootSchema?.definitions as Record<string, unknown> | undefined);
|
||||
const fallback = defs?.DetectorConfig;
|
||||
|
||||
return isSchemaObject(fallback) ? (fallback as RJSFSchema) : undefined;
|
||||
}, [schema, registry.rootSchema]);
|
||||
|
||||
const detectorSchemas = useMemo<DetectorSchemaEntry[]>(() => {
|
||||
const entries: DetectorSchemaEntry[] = [];
|
||||
getUnionSchemas(detectorConfigSchema).forEach((schema) => {
|
||||
const types = getTypeValues(schema);
|
||||
types.forEach((type) => {
|
||||
entries.push({ type, schema });
|
||||
});
|
||||
});
|
||||
return entries;
|
||||
}, [detectorConfigSchema]);
|
||||
|
||||
const detectorSchemaByType = useMemo(() => {
|
||||
const map = new Map<string, RJSFSchema>();
|
||||
detectorSchemas.forEach(({ type, schema }) => {
|
||||
if (!map.has(type)) {
|
||||
map.set(type, schema);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [detectorSchemas]);
|
||||
|
||||
const availableTypes = useMemo(
|
||||
() => detectorSchemas.map((entry) => entry.type),
|
||||
[detectorSchemas],
|
||||
);
|
||||
|
||||
const orderedTypes = useMemo(() => {
|
||||
if (!typeOrder.length) {
|
||||
return availableTypes;
|
||||
}
|
||||
|
||||
const availableSet = new Set(availableTypes);
|
||||
const ordered = typeOrder.filter((type) => availableSet.has(type));
|
||||
const orderedSet = new Set(ordered);
|
||||
const remaining = availableTypes.filter((type) => !orderedSet.has(type));
|
||||
return [...ordered, ...remaining];
|
||||
}, [availableTypes, typeOrder]);
|
||||
|
||||
const formData = isJsonObject(rawFormData) ? rawFormData : {};
|
||||
const detectors = formData as JsonObject;
|
||||
|
||||
const [addType, setAddType] = useState<string | undefined>(orderedTypes[0]);
|
||||
const [addError, setAddError] = useState<string | undefined>();
|
||||
const [renameDrafts, setRenameDrafts] = useState<Record<string, string>>({});
|
||||
const [renameErrors, setRenameErrors] = useState<Record<string, string>>({});
|
||||
const [typeErrors, setTypeErrors] = useState<Record<string, string>>({});
|
||||
const [openKeys, setOpenKeys] = useState<Set<string>>(
|
||||
() => new Set(Object.keys(detectors)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderedTypes.length) {
|
||||
setAddType(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!addType || !orderedTypes.includes(addType)) {
|
||||
setAddType(orderedTypes[0]);
|
||||
}
|
||||
}, [orderedTypes, addType]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set<string>();
|
||||
Object.keys(detectors).forEach((key) => {
|
||||
if (prev.has(key)) {
|
||||
next.add(key);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
setRenameDrafts((prev) => {
|
||||
const next: Record<string, string> = {};
|
||||
Object.keys(detectors).forEach((key) => {
|
||||
if (prev[key] !== undefined) {
|
||||
next[key] = prev[key];
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
setRenameErrors((prev) => {
|
||||
const next: Record<string, string> = {};
|
||||
Object.keys(detectors).forEach((key) => {
|
||||
if (prev[key] !== undefined) {
|
||||
next[key] = prev[key];
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
setTypeErrors((prev) => {
|
||||
const next: Record<string, string> = {};
|
||||
Object.keys(detectors).forEach((key) => {
|
||||
if (prev[key] !== undefined) {
|
||||
next[key] = prev[key];
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [detectors]);
|
||||
|
||||
const updateDetectors = useCallback(
|
||||
(nextDetectors: JsonObject, path?: FieldPathList) => {
|
||||
onChange(nextDetectors as unknown, path ?? fieldPathId.path);
|
||||
},
|
||||
[fieldPathId.path, onChange],
|
||||
);
|
||||
|
||||
const getTypeLabel = useCallback(
|
||||
(type: string) =>
|
||||
t(`${sectionPrefix}.${type}.label`, {
|
||||
ns: configNamespace,
|
||||
defaultValue: humanizeKey(type),
|
||||
}),
|
||||
[t, sectionPrefix, configNamespace],
|
||||
);
|
||||
|
||||
const getTypeDescription = useCallback(
|
||||
(type: string) =>
|
||||
t(`${sectionPrefix}.${type}.description`, {
|
||||
ns: configNamespace,
|
||||
defaultValue: "",
|
||||
}),
|
||||
[t, sectionPrefix, configNamespace],
|
||||
);
|
||||
|
||||
const shouldShowRestartForPath = useCallback(
|
||||
(path: Array<string | number>) =>
|
||||
requiresRestartForFieldPath(
|
||||
path,
|
||||
restartRequired,
|
||||
defaultRequiresRestart,
|
||||
),
|
||||
[defaultRequiresRestart, restartRequired],
|
||||
);
|
||||
|
||||
const renderRestartIcon = (isRequired: boolean) => {
|
||||
if (!isRequired) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RestartRequiredIndicator className="ml-2" />;
|
||||
};
|
||||
|
||||
const isSingleInstanceType = useCallback(
|
||||
(type: string) => !multiInstanceSet.has(type),
|
||||
[multiInstanceSet],
|
||||
);
|
||||
|
||||
const getDetectorDefaults = useCallback(
|
||||
(type: string) => {
|
||||
const schema = detectorSchemaByType.get(type);
|
||||
if (!schema) {
|
||||
return { type };
|
||||
}
|
||||
|
||||
const base = { type } as Record<string, unknown>;
|
||||
const withDefaults = applySchemaDefaults(schema, base);
|
||||
return { ...withDefaults, type } as Record<string, unknown>;
|
||||
},
|
||||
[detectorSchemaByType],
|
||||
);
|
||||
|
||||
const resolveDuplicateType = useCallback(
|
||||
(targetType: string, excludeKey?: string) => {
|
||||
return Object.entries(detectors).some(([key, value]) => {
|
||||
if (excludeKey && key === excludeKey) {
|
||||
return false;
|
||||
}
|
||||
return getInstanceType(value) === targetType;
|
||||
});
|
||||
},
|
||||
[detectors],
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
if (!addType) {
|
||||
setAddError(
|
||||
t("selectItem", {
|
||||
ns: "common",
|
||||
defaultValue: "Select {{item}}",
|
||||
item: t("detectors.type.label", {
|
||||
ns: configNamespace,
|
||||
defaultValue: "Type",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSingleInstanceType(addType) && resolveDuplicateType(addType)) {
|
||||
setAddError(
|
||||
t("configForm.detectors.singleType", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Only one {{type}} detector is allowed.",
|
||||
type: getTypeLabel(addType),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseKey = addType;
|
||||
let nextKey = baseKey;
|
||||
let index = 2;
|
||||
while (Object.prototype.hasOwnProperty.call(detectors, nextKey)) {
|
||||
nextKey = `${baseKey}${index}`;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const nextDetectors = {
|
||||
...detectors,
|
||||
[nextKey]: getDetectorDefaults(addType),
|
||||
} as JsonObject;
|
||||
|
||||
setAddError(undefined);
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(nextKey);
|
||||
return next;
|
||||
});
|
||||
|
||||
updateDetectors(nextDetectors);
|
||||
}, [
|
||||
addType,
|
||||
t,
|
||||
configNamespace,
|
||||
detectors,
|
||||
getDetectorDefaults,
|
||||
getTypeLabel,
|
||||
isSingleInstanceType,
|
||||
resolveDuplicateType,
|
||||
updateDetectors,
|
||||
]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(key: string) => {
|
||||
const { [key]: _, ...rest } = detectors;
|
||||
updateDetectors(rest as JsonObject);
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[detectors, updateDetectors],
|
||||
);
|
||||
|
||||
const commitRename = useCallback(
|
||||
(key: string, nextKey: string) => {
|
||||
const trimmed = nextKey.trim();
|
||||
if (!trimmed) {
|
||||
setRenameErrors((prev) => ({
|
||||
...prev,
|
||||
[key]: t("configForm.detectors.keyRequired", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Detector name is required.",
|
||||
}),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed !== key && detectors[trimmed] !== undefined) {
|
||||
setRenameErrors((prev) => ({
|
||||
...prev,
|
||||
[key]: t("configForm.detectors.keyDuplicate", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Detector name already exists.",
|
||||
}),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setRenameErrors((prev) => {
|
||||
const { [key]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
setRenameDrafts((prev) => {
|
||||
const { [key]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
if (trimmed === key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { [key]: value, ...rest } = detectors;
|
||||
const nextDetectors = { ...rest, [trimmed]: value } as JsonObject;
|
||||
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.delete(key)) {
|
||||
next.add(trimmed);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
updateDetectors(nextDetectors);
|
||||
},
|
||||
[detectors, t, updateDetectors],
|
||||
);
|
||||
|
||||
const handleTypeChange = useCallback(
|
||||
(key: string, nextType: string) => {
|
||||
const currentType = getInstanceType(detectors[key]);
|
||||
if (!nextType || nextType === currentType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isSingleInstanceType(nextType) &&
|
||||
resolveDuplicateType(nextType, key)
|
||||
) {
|
||||
setTypeErrors((prev) => ({
|
||||
...prev,
|
||||
[key]: t("configForm.detectors.singleType", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Only one {{type}} detector is allowed.",
|
||||
type: getTypeLabel(nextType),
|
||||
}),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setTypeErrors((prev) => {
|
||||
const { [key]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
const nextDetectors = {
|
||||
...detectors,
|
||||
[key]: getDetectorDefaults(nextType),
|
||||
} as JsonObject;
|
||||
|
||||
updateDetectors(nextDetectors);
|
||||
},
|
||||
[
|
||||
detectors,
|
||||
getDetectorDefaults,
|
||||
getTypeLabel,
|
||||
isSingleInstanceType,
|
||||
resolveDuplicateType,
|
||||
t,
|
||||
updateDetectors,
|
||||
],
|
||||
);
|
||||
|
||||
const getInstanceUiSchema = useCallback(
|
||||
(type: string) => {
|
||||
const baseUiSchema =
|
||||
(uiSchema?.additionalProperties as UiSchema | undefined) ?? {};
|
||||
const globalHidden = buildHiddenUiSchema(globalHiddenFields);
|
||||
const hiddenOverrides = buildHiddenUiSchema(hiddenByType[type] ?? []);
|
||||
const typeHidden = { type: { "ui:widget": "hidden" } } as UiSchema;
|
||||
const nestedOverrides = {
|
||||
"ui:options": {
|
||||
disableNestedCard: true,
|
||||
},
|
||||
} as UiSchema;
|
||||
|
||||
const withGlobalHidden = mergeUiSchema(baseUiSchema, globalHidden);
|
||||
const withTypeHidden = mergeUiSchema(withGlobalHidden, hiddenOverrides);
|
||||
const withTypeHiddenAndOptions = mergeUiSchema(
|
||||
withTypeHidden,
|
||||
typeHidden,
|
||||
);
|
||||
return mergeUiSchema(withTypeHiddenAndOptions, nestedOverrides);
|
||||
},
|
||||
[globalHiddenFields, hiddenByType, uiSchema?.additionalProperties],
|
||||
);
|
||||
|
||||
const renderInstanceForm = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
const SchemaField = registry.fields.SchemaField;
|
||||
const type = getInstanceType(value);
|
||||
const schema = type ? detectorSchemaByType.get(type) : undefined;
|
||||
|
||||
if (!SchemaField || !schema || !type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const instanceUiSchema = getInstanceUiSchema(type);
|
||||
const instanceFieldPathId = toFieldPathId(
|
||||
key,
|
||||
registry.globalFormOptions,
|
||||
fieldPathId.path,
|
||||
);
|
||||
|
||||
const instanceErrorSchema = (
|
||||
errorSchema as Record<string, ErrorSchema> | undefined
|
||||
)?.[key];
|
||||
|
||||
const handleInstanceChange = (
|
||||
nextValue: unknown,
|
||||
path: FieldPathList,
|
||||
errors?: ErrorSchema,
|
||||
id?: string,
|
||||
) => {
|
||||
onChange(nextValue, path, errors, id);
|
||||
};
|
||||
|
||||
return (
|
||||
<SchemaField
|
||||
name={key}
|
||||
schema={schema}
|
||||
uiSchema={instanceUiSchema}
|
||||
fieldPathId={instanceFieldPathId}
|
||||
formData={value}
|
||||
errorSchema={instanceErrorSchema}
|
||||
onChange={handleInstanceChange}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
registry={registry}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
hideError={hideError}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
detectorSchemaByType,
|
||||
getInstanceUiSchema,
|
||||
disabled,
|
||||
errorSchema,
|
||||
fieldPathId,
|
||||
hideError,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
readonly,
|
||||
registry,
|
||||
],
|
||||
);
|
||||
|
||||
if (!availableTypes.length) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("configForm.detectors.noSchema", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "No detector schemas are available.",
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const detectorEntries = Object.entries(detectors);
|
||||
const isDisabled = Boolean(disabled || readonly);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{detectorEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("configForm.detectors.none", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "No detector instances configured.",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{detectorEntries.map(([key, value]) => {
|
||||
const type = getInstanceType(value) ?? "";
|
||||
const typeLabel = type ? getTypeLabel(type) : key;
|
||||
const typeDescription = type ? getTypeDescription(type) : "";
|
||||
const isOpen = openKeys.has(key);
|
||||
const renameDraft = renameDrafts[key] ?? key;
|
||||
const detectorPath = [...fieldPathId.path, key];
|
||||
const detectorTypePath = [...detectorPath, "type"];
|
||||
const detectorTypeRequiresRestart =
|
||||
shouldShowRestartForPath(detectorTypePath);
|
||||
|
||||
return (
|
||||
<div key={key} className="rounded-lg border bg-card">
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (open) {
|
||||
next.add(key);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="mt-0.5"
|
||||
>
|
||||
{isOpen ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<div>
|
||||
<div className="flex items-center text-sm font-medium">
|
||||
{typeLabel}
|
||||
{renderRestartIcon(detectorTypeRequiresRestart)}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{key}
|
||||
</span>
|
||||
</div>
|
||||
{typeDescription && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{typeDescription}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => handleRemove(key)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-4 border-t p-4">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center">
|
||||
{t("label.ID", {
|
||||
ns: "common",
|
||||
defaultValue: "ID",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
value={renameDraft}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => {
|
||||
setRenameDrafts((prev) => ({
|
||||
...prev,
|
||||
[key]: event.target.value,
|
||||
}));
|
||||
}}
|
||||
onBlur={(event) =>
|
||||
commitRename(key, event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitRename(key, renameDraft);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("field.internalID", {
|
||||
ns: "common",
|
||||
defaultValue:
|
||||
"The Internal ID Frigate uses in the configuration and database",
|
||||
})}
|
||||
</p>
|
||||
{renameErrors[key] && (
|
||||
<p className="text-xs text-danger">
|
||||
{renameErrors[key]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-3 space-y-2">
|
||||
<Label className="flex items-center">
|
||||
{t("detectors.type.label", {
|
||||
ns: configNamespace,
|
||||
defaultValue: "Type",
|
||||
})}
|
||||
</Label>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(value) =>
|
||||
handleTypeChange(key, value)
|
||||
}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t("selectItem", {
|
||||
ns: "common",
|
||||
defaultValue: "Select {{item}}",
|
||||
item: t("detectors.type.label", {
|
||||
ns: configNamespace,
|
||||
defaultValue: "Type",
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{orderedTypes.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{getTypeLabel(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{typeErrors[key] && (
|
||||
<p className="text-xs text-danger">
|
||||
{typeErrors[key]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(readonly && "opacity-90")}>
|
||||
{renderInstanceForm(key, value)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-start pt-5">
|
||||
<div className="w-full max-w-lg rounded-lg border bg-card p-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{t("configForm.detectors.add", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Add detector",
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label>
|
||||
{t("detectors.type.label", {
|
||||
ns: configNamespace,
|
||||
defaultValue: "Type",
|
||||
})}
|
||||
</Label>
|
||||
<Select
|
||||
value={addType ?? ""}
|
||||
onValueChange={(value) => {
|
||||
setAddError(undefined);
|
||||
setAddType(value);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t("selectItem", {
|
||||
ns: "common",
|
||||
defaultValue: "Select {{item}}",
|
||||
item: t("detectors.type.label", {
|
||||
ns: configNamespace,
|
||||
defaultValue: "Type",
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{orderedTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{getTypeLabel(type)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{addError && <p className="text-xs text-danger">{addError}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAdd}
|
||||
disabled={isDisabled}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("button.add", {
|
||||
ns: "common",
|
||||
defaultValue: "Add",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* LayoutGridField - RJSF field for responsive, semantic grid layouts
|
||||
*
|
||||
* Overview:
|
||||
* - Apply a responsive grid to object properties using `ui:layoutGrid` while
|
||||
* preserving the default `ObjectFieldTemplate` behavior (cards, nested
|
||||
* collapsibles, add button, and i18n).
|
||||
* - Falls back to the original template when `ui:layoutGrid` is not present.
|
||||
*
|
||||
* Capabilities:
|
||||
* - 12-column grid logic. `ui:col` accepts a number (1-12) or a Tailwind class
|
||||
* string (e.g. "col-span-12 md:col-span-4") for responsive column widths.
|
||||
* - Per-row and global class overrides:
|
||||
* - `ui:options.layoutGrid.rowClassName` (default: "grid-cols-12") is merged
|
||||
* with the base `grid gap-4` classes.
|
||||
* - `ui:options.layoutGrid.advancedRowClassName` (default: "grid-cols-12")
|
||||
* controls advanced-section rows.
|
||||
* - Per-row `ui:className` and per-column `ui:className`/`className` are
|
||||
* supported for fine-grained layout control.
|
||||
* - Optional `useGridForAdvanced` (via `ui:options.layoutGrid`) to toggle
|
||||
* whether advanced fields use the grid or fall back to stacked layout.
|
||||
* - Integrates with `ui:groups` to show semantic group labels (resolved via
|
||||
* `config/groups` i18n). If a layout row contains fields from the same group,
|
||||
* that row shows the group label above it; leftover or ungrouped fields are
|
||||
* rendered after the configured rows.
|
||||
* - Hidden fields (`ui:widget: "hidden"`) are ignored.
|
||||
*
|
||||
* Internationalization
|
||||
* - Advanced collapsible labels use `label.advancedSettingsCount` and
|
||||
* `label.advancedCount` in the `common` namespace.
|
||||
* - Group labels are looked up in `config/groups` (uses `sectionI18nPrefix`
|
||||
* when available).
|
||||
*
|
||||
* Usage examples:
|
||||
* Basic:
|
||||
* {
|
||||
* "ui:field": "LayoutGridField",
|
||||
* "ui:layoutGrid": [
|
||||
* { "ui:row": ["field1", "field2"] },
|
||||
* { "ui:row": ["field3"] }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* Custom columns and responsive classes:
|
||||
* {
|
||||
* "ui:field": "LayoutGridField",
|
||||
* "ui:options": {
|
||||
* "layoutGrid": { "rowClassName": "grid-cols-12 md:grid-cols-6", "useGridForAdvanced": true }
|
||||
* },
|
||||
* "ui:layoutGrid": [
|
||||
* {
|
||||
* "ui:row": [
|
||||
* { "field1": { "ui:col": "col-span-12 md:col-span-4", "ui:className": "md:col-start-2" } },
|
||||
* { "field2": { "ui:col": 4 } }
|
||||
* ],
|
||||
* "ui:className": "gap-6"
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* Groups and rows:
|
||||
* {
|
||||
* "ui:field": "LayoutGridField",
|
||||
* "ui:groups": { "resolution": ["fps","width","height"], "tracking": ["min_initialized","max_disappeared"] },
|
||||
* "ui:layoutGrid": [
|
||||
* { "ui:row": ["enabled"] },
|
||||
* { "ui:row": ["fps","width","height"] }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* Notes:
|
||||
* - `ui:layoutGrid` must be an array; non-array values are ignored and the
|
||||
* default ObjectFieldTemplate is used instead.
|
||||
* - This implementation adheres to RJSF patterns (use `ui:options`,
|
||||
* `ui:className`, and `ui:layoutGrid` as documented) while adding a few
|
||||
* Frigate-specific conveniences (defaults and Tailwind-friendly class
|
||||
* handling).
|
||||
*/
|
||||
|
||||
import type { FieldProps, ObjectFieldTemplateProps } from "@rjsf/utils";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConfigFormContext } from "@/types/configForm";
|
||||
import {
|
||||
getDomainFromNamespace,
|
||||
hasOverrideAtPath,
|
||||
humanizeKey,
|
||||
} from "../utils";
|
||||
import { AddPropertyButton, AdvancedCollapsible } from "../components";
|
||||
|
||||
type LayoutGridColumnConfig = {
|
||||
"ui:col"?: number | string;
|
||||
"ui:className"?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type LayoutRow = {
|
||||
"ui:row": Array<string | Record<string, LayoutGridColumnConfig>>;
|
||||
"ui:className"?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type LayoutGrid = LayoutRow[];
|
||||
|
||||
type LayoutGridOptions = {
|
||||
rowClassName?: string;
|
||||
advancedRowClassName?: string;
|
||||
useGridForAdvanced?: boolean;
|
||||
};
|
||||
|
||||
interface PropertyElement {
|
||||
name: string;
|
||||
content: React.ReactElement;
|
||||
}
|
||||
|
||||
function isObjectLikeElement(item: PropertyElement) {
|
||||
const fieldSchema = item.content.props?.schema as
|
||||
| { type?: string | string[] }
|
||||
| undefined;
|
||||
return fieldSchema?.type === "object";
|
||||
}
|
||||
|
||||
// Custom ObjectFieldTemplate wrapper that applies grid layout
|
||||
function GridLayoutObjectFieldTemplate(
|
||||
props: ObjectFieldTemplateProps,
|
||||
originalObjectFieldTemplate: React.ComponentType<ObjectFieldTemplateProps>,
|
||||
) {
|
||||
const {
|
||||
uiSchema,
|
||||
properties,
|
||||
registry,
|
||||
schema,
|
||||
onAddProperty,
|
||||
formData,
|
||||
disabled,
|
||||
readonly,
|
||||
} = props;
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
const { t } = useTranslation(["common", "config/groups"]);
|
||||
|
||||
// Use the original ObjectFieldTemplate passed as parameter, not from registry
|
||||
const ObjectFieldTemplate = originalObjectFieldTemplate;
|
||||
|
||||
// Get layout configuration
|
||||
const layoutGrid = Array.isArray(uiSchema?.["ui:layoutGrid"])
|
||||
? (uiSchema?.["ui:layoutGrid"] as LayoutGrid)
|
||||
: [];
|
||||
const layoutGridOptions =
|
||||
(uiSchema?.["ui:options"] as { layoutGrid?: LayoutGridOptions } | undefined)
|
||||
?.layoutGrid ?? {};
|
||||
const baseRowClassName = layoutGridOptions.rowClassName ?? "grid-cols-12";
|
||||
const advancedRowClassName =
|
||||
layoutGridOptions.advancedRowClassName ?? "grid-cols-12";
|
||||
const useGridForAdvanced = layoutGridOptions.useGridForAdvanced ?? true;
|
||||
const groupDefinitions =
|
||||
(uiSchema?.["ui:groups"] as Record<string, string[]> | undefined) || {};
|
||||
const overrides = formContext?.overrides;
|
||||
const fieldPath = props.fieldPathId.path;
|
||||
|
||||
const isPathModified = (path: Array<string | number>) =>
|
||||
hasOverrideAtPath(overrides, path, formContext?.formData);
|
||||
|
||||
// Override the properties rendering with grid layout
|
||||
const isHiddenProp = (prop: (typeof properties)[number]) =>
|
||||
prop.content.props.uiSchema?.["ui:widget"] === "hidden";
|
||||
|
||||
const visibleProps = properties.filter((prop) => !isHiddenProp(prop));
|
||||
|
||||
// Separate regular and advanced properties
|
||||
const advancedProps = visibleProps.filter(
|
||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
|
||||
);
|
||||
const regularProps = visibleProps.filter(
|
||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
|
||||
);
|
||||
const hasModifiedAdvanced = advancedProps.some((prop) =>
|
||||
isPathModified([...fieldPath, prop.name]),
|
||||
);
|
||||
const [showAdvanced, setShowAdvanced] = useState(hasModifiedAdvanced);
|
||||
|
||||
// If no layout grid is defined, use the default template
|
||||
if (layoutGrid.length === 0) {
|
||||
return <ObjectFieldTemplate {...props} />;
|
||||
}
|
||||
|
||||
const domain = getDomainFromNamespace(formContext?.i18nNamespace);
|
||||
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
|
||||
|
||||
const getGroupLabel = (groupKey: string) => {
|
||||
if (domain && sectionI18nPrefix) {
|
||||
return t(`${sectionI18nPrefix}.${domain}.${groupKey}`, {
|
||||
ns: "config/groups",
|
||||
defaultValue: humanizeKey(groupKey),
|
||||
});
|
||||
}
|
||||
|
||||
return t(`groups.${groupKey}`, {
|
||||
ns: "config/groups",
|
||||
defaultValue: humanizeKey(groupKey),
|
||||
});
|
||||
};
|
||||
|
||||
// Render fields using the layout grid structure
|
||||
const renderGridLayout = (items: PropertyElement[], rowClassName: string) => {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
const itemMap = new Map(items.map((item) => [item.name, item]));
|
||||
const renderedFields = new Set<string>();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{layoutGrid.map((rowDef, rowIndex) => {
|
||||
const rowItems = rowDef["ui:row"];
|
||||
const cols: React.ReactNode[] = [];
|
||||
|
||||
rowItems.forEach((colDef, colIndex) => {
|
||||
let fieldName: string;
|
||||
let colSpan: number | string = 12; // Default to full width
|
||||
let colClassName: string | undefined;
|
||||
|
||||
if (typeof colDef === "string") {
|
||||
fieldName = colDef;
|
||||
} else {
|
||||
// Object with field name as key and ui:col as value
|
||||
const entries = Object.entries(colDef);
|
||||
if (entries.length === 0) return;
|
||||
const [name, config] = entries[0];
|
||||
fieldName = name;
|
||||
colSpan = config["ui:col"] ?? 12;
|
||||
colClassName = config["ui:className"] ?? config.className;
|
||||
}
|
||||
|
||||
const item = itemMap.get(fieldName);
|
||||
if (!item) return;
|
||||
|
||||
renderedFields.add(fieldName);
|
||||
|
||||
// Calculate column width class (using 12-column grid)
|
||||
const colSpanClass =
|
||||
typeof colSpan === "string" ? colSpan : `col-span-${colSpan}`;
|
||||
const colClass = cn(colSpanClass, colClassName);
|
||||
|
||||
cols.push(
|
||||
<div key={`${rowIndex}-${colIndex}`} className={colClass}>
|
||||
{item.content}
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
if (cols.length === 0) return null;
|
||||
|
||||
const rowClass = cn(
|
||||
"grid gap-4",
|
||||
rowClassName,
|
||||
rowDef["ui:className"],
|
||||
rowDef.className,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={rowIndex} className={rowClass}>
|
||||
{cols}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{Array.from(itemMap.keys())
|
||||
.filter((name) => !renderedFields.has(name))
|
||||
.map((name) => {
|
||||
const item = itemMap.get(name);
|
||||
return item ? (
|
||||
<div key={name} className="space-y-6">
|
||||
{item.content}
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderGroupedGridLayout = (
|
||||
items: PropertyElement[],
|
||||
rowClassName: string,
|
||||
) => {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Object.keys(groupDefinitions).length === 0) {
|
||||
return renderGridLayout(items, rowClassName);
|
||||
}
|
||||
|
||||
const itemMap = new Map(items.map((item) => [item.name, item]));
|
||||
const renderedFields = new Set<string>();
|
||||
const renderedGroups = new Set<string>();
|
||||
const groupMap = new Map<string, string>();
|
||||
|
||||
Object.entries(groupDefinitions).forEach(([groupKey, fields]) => {
|
||||
fields.forEach((field) => {
|
||||
groupMap.set(field, groupKey);
|
||||
});
|
||||
});
|
||||
|
||||
const rows = layoutGrid
|
||||
.map((rowDef, rowIndex) => {
|
||||
const rowItems = rowDef["ui:row"];
|
||||
const cols: React.ReactNode[] = [];
|
||||
const rowFieldNames: string[] = [];
|
||||
|
||||
rowItems.forEach((colDef, colIndex) => {
|
||||
let fieldName: string;
|
||||
let colSpan: number | string = 12;
|
||||
let colClassName: string | undefined;
|
||||
|
||||
if (typeof colDef === "string") {
|
||||
fieldName = colDef;
|
||||
} else {
|
||||
const entries = Object.entries(colDef);
|
||||
if (entries.length === 0) return;
|
||||
const [name, config] = entries[0];
|
||||
fieldName = name;
|
||||
colSpan = config["ui:col"] ?? 12;
|
||||
colClassName = config["ui:className"] ?? config.className;
|
||||
}
|
||||
|
||||
const item = itemMap.get(fieldName);
|
||||
if (!item) return;
|
||||
|
||||
renderedFields.add(fieldName);
|
||||
rowFieldNames.push(fieldName);
|
||||
|
||||
const colSpanClass =
|
||||
typeof colSpan === "string" ? colSpan : `col-span-${colSpan}`;
|
||||
const colClass = cn(colSpanClass, colClassName);
|
||||
|
||||
cols.push(
|
||||
<div key={`${rowIndex}-${colIndex}`} className={colClass}>
|
||||
{item.content}
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
if (cols.length === 0) return null;
|
||||
|
||||
const rowClass = cn(
|
||||
"grid gap-4",
|
||||
rowClassName,
|
||||
rowDef["ui:className"],
|
||||
rowDef.className,
|
||||
);
|
||||
|
||||
const rowGroupKeys = rowFieldNames
|
||||
.map((name) => groupMap.get(name))
|
||||
.filter(Boolean) as string[];
|
||||
const rowGroupKey =
|
||||
rowGroupKeys.length > 0 &&
|
||||
rowGroupKeys.length === rowFieldNames.length &&
|
||||
new Set(rowGroupKeys).size === 1
|
||||
? rowGroupKeys[0]
|
||||
: undefined;
|
||||
const showGroupLabel = rowGroupKey && !renderedGroups.has(rowGroupKey);
|
||||
|
||||
if (showGroupLabel) {
|
||||
renderedGroups.add(rowGroupKey);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rowIndex}
|
||||
className={cn(
|
||||
"space-y-4",
|
||||
rowGroupKey &&
|
||||
"rounded-lg border border-border/70 bg-card/30 p-4",
|
||||
)}
|
||||
>
|
||||
{showGroupLabel && (
|
||||
<div className="border-b border-border/60 pb-2 text-sm font-semibold text-primary-variant">
|
||||
{getGroupLabel(rowGroupKey)}
|
||||
</div>
|
||||
)}
|
||||
<div className={rowClass}>{cols}</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const remainingItems = Array.from(itemMap.keys())
|
||||
.filter((name) => !renderedFields.has(name))
|
||||
.map((name) => itemMap.get(name))
|
||||
.filter(Boolean) as PropertyElement[];
|
||||
|
||||
const groupedLeftovers = new Map<string, PropertyElement[]>();
|
||||
const ungroupedLeftovers: PropertyElement[] = [];
|
||||
|
||||
remainingItems.forEach((item) => {
|
||||
const groupKey = groupMap.get(item.name);
|
||||
if (groupKey) {
|
||||
const existing = groupedLeftovers.get(groupKey);
|
||||
if (existing) {
|
||||
existing.push(item);
|
||||
} else {
|
||||
groupedLeftovers.set(groupKey, [item]);
|
||||
}
|
||||
} else {
|
||||
ungroupedLeftovers.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
const leftoverSections: React.ReactNode[] = [];
|
||||
|
||||
groupedLeftovers.forEach((groupItems, groupKey) => {
|
||||
const showGroupLabel = !renderedGroups.has(groupKey);
|
||||
if (showGroupLabel) {
|
||||
renderedGroups.add(groupKey);
|
||||
}
|
||||
|
||||
leftoverSections.push(
|
||||
<div
|
||||
key={groupKey}
|
||||
className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4"
|
||||
>
|
||||
{showGroupLabel && (
|
||||
<div className="border-b border-border/60 pb-2 text-sm font-semibold text-primary-variant">
|
||||
{getGroupLabel(groupKey)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
{groupItems.map((item) => (
|
||||
<div key={item.name}>{item.content}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
if (ungroupedLeftovers.length > 0) {
|
||||
leftoverSections.push(
|
||||
<div
|
||||
key="ungrouped-leftovers"
|
||||
className={cn(
|
||||
"space-y-6",
|
||||
(rows.length > 0 || groupedLeftovers.size > 0) && "pt-2",
|
||||
)}
|
||||
>
|
||||
{ungroupedLeftovers.map((item) => (
|
||||
<div
|
||||
key={item.name}
|
||||
className={cn(
|
||||
groupedLeftovers.size > 0 &&
|
||||
!isObjectLikeElement(item) &&
|
||||
"px-4",
|
||||
)}
|
||||
>
|
||||
{item.content}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{rows}
|
||||
{leftoverSections}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStackedLayout = (items: PropertyElement[]) => {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{items.map((item) => (
|
||||
<div key={item.name}>{item.content}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const regularLayout = renderGroupedGridLayout(regularProps, baseRowClassName);
|
||||
const advancedLayout = useGridForAdvanced
|
||||
? renderGroupedGridLayout(advancedProps, advancedRowClassName)
|
||||
: renderStackedLayout(advancedProps);
|
||||
|
||||
// Create modified props with custom property rendering
|
||||
// Render using the original template but with our custom content
|
||||
const isRoot = registry?.rootSchema === props.schema;
|
||||
|
||||
if (isRoot) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{regularLayout}
|
||||
<AddPropertyButton
|
||||
onAddProperty={onAddProperty}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
<AdvancedCollapsible
|
||||
count={advancedProps.length}
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
isRoot
|
||||
>
|
||||
{advancedLayout}
|
||||
</AdvancedCollapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// We need to inject our custom rendering into the template
|
||||
// Since we can't directly modify the template's internal rendering,
|
||||
// we'll render the full structure ourselves
|
||||
return (
|
||||
<ObjectFieldTemplate {...props}>
|
||||
<div className="space-y-4">
|
||||
{regularLayout}
|
||||
<AddPropertyButton
|
||||
onAddProperty={onAddProperty}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
<AdvancedCollapsible
|
||||
count={advancedProps.length}
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
>
|
||||
{advancedLayout}
|
||||
</AdvancedCollapsible>
|
||||
</div>
|
||||
</ObjectFieldTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutGridField(props: FieldProps) {
|
||||
const { registry, schema, uiSchema, idSchema, formData } = props;
|
||||
|
||||
// Store the original ObjectFieldTemplate before any modifications
|
||||
const originalObjectFieldTemplate = registry.templates.ObjectFieldTemplate;
|
||||
|
||||
// Get the ObjectField component from the registry
|
||||
const ObjectField = registry.fields.ObjectField;
|
||||
|
||||
// Create a modified registry with our custom template
|
||||
// But we'll pass the original template to it to prevent circular reference
|
||||
const gridObjectFieldTemplate = useCallback(
|
||||
(tProps: ObjectFieldTemplateProps) =>
|
||||
GridLayoutObjectFieldTemplate(tProps, originalObjectFieldTemplate),
|
||||
[originalObjectFieldTemplate],
|
||||
);
|
||||
|
||||
const modifiedRegistry = useMemo(
|
||||
() => ({
|
||||
...registry,
|
||||
templates: {
|
||||
...registry.templates,
|
||||
ObjectFieldTemplate: gridObjectFieldTemplate,
|
||||
},
|
||||
}),
|
||||
[registry, gridObjectFieldTemplate],
|
||||
);
|
||||
|
||||
// Delegate to ObjectField with the modified registry
|
||||
return (
|
||||
<ObjectField
|
||||
{...props}
|
||||
registry={modifiedRegistry}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
idSchema={idSchema}
|
||||
formData={formData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
LuPlus,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import get from "lodash/get";
|
||||
import { isSubtreeModified } from "../utils";
|
||||
|
||||
type ReplaceRule = {
|
||||
pattern?: string;
|
||||
replacement?: string;
|
||||
};
|
||||
|
||||
function getItemSchema(schema: RJSFSchema): RJSFSchema | undefined {
|
||||
const items = schema.items;
|
||||
if (!items || typeof items !== "object" || Array.isArray(items)) {
|
||||
return undefined;
|
||||
}
|
||||
return items as RJSFSchema;
|
||||
}
|
||||
|
||||
function getPropertyTitle(itemSchema: RJSFSchema | undefined, key: string) {
|
||||
const props = (itemSchema as { properties?: Record<string, RJSFSchema> })
|
||||
?.properties;
|
||||
const title = props?.[key]?.title;
|
||||
return typeof title === "string" ? title : undefined;
|
||||
}
|
||||
|
||||
export function ReplaceRulesField(props: FieldProps) {
|
||||
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
const rules: ReplaceRule[] = useMemo(() => {
|
||||
if (!Array.isArray(formData)) {
|
||||
return [];
|
||||
}
|
||||
return formData as ReplaceRule[];
|
||||
}, [formData]);
|
||||
|
||||
const itemSchema = useMemo(
|
||||
() => getItemSchema(schema as RJSFSchema),
|
||||
[schema],
|
||||
);
|
||||
const title = (schema as RJSFSchema).title;
|
||||
const description = (schema as RJSFSchema).description;
|
||||
const patternTitle = getPropertyTitle(itemSchema, "pattern");
|
||||
const replacementTitle = getPropertyTitle(itemSchema, "replacement");
|
||||
|
||||
const hasItems = rules.length > 0;
|
||||
const emptyPath = useMemo(() => [] as FieldPathList, []);
|
||||
const fieldPath =
|
||||
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
|
||||
emptyPath;
|
||||
|
||||
const isModified = useMemo(() => {
|
||||
const baselineRoot = formContext?.baselineFormData;
|
||||
const baselineValue = baselineRoot
|
||||
? get(baselineRoot, fieldPath)
|
||||
: undefined;
|
||||
return isSubtreeModified(
|
||||
rules,
|
||||
baselineValue,
|
||||
formContext?.overrides,
|
||||
fieldPath,
|
||||
formContext?.formData,
|
||||
);
|
||||
}, [fieldPath, formContext, rules]);
|
||||
|
||||
const [open, setOpen] = useState(hasItems || isModified);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModified) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [isModified]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasItems) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [hasItems]);
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
const next = [...rules, { pattern: "", replacement: "" }];
|
||||
onChange(next, fieldPath);
|
||||
}, [fieldPath, onChange, rules]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(index: number) => {
|
||||
const next = rules.filter((_, i) => i !== index);
|
||||
onChange(next, fieldPath);
|
||||
},
|
||||
[fieldPath, onChange, rules],
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(index: number, patch: Partial<ReplaceRule>) => {
|
||||
const next = rules.map((rule, i) => {
|
||||
if (i !== index) {
|
||||
return rule;
|
||||
}
|
||||
return {
|
||||
...rule,
|
||||
...patch,
|
||||
};
|
||||
});
|
||||
onChange(next, fieldPath);
|
||||
},
|
||||
[fieldPath, onChange, rules],
|
||||
);
|
||||
|
||||
const baseId = idSchema?.$id || "replace_rules";
|
||||
const deleteLabel = t("button.delete", {
|
||||
ns: "common",
|
||||
defaultValue: "Delete",
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle
|
||||
className={cn("text-sm", isModified && "text-danger")}
|
||||
>
|
||||
{title}
|
||||
</CardTitle>
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{open ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-4 p-4 pt-0">
|
||||
{rules.length > 0 && (
|
||||
<div className="grid grid-cols-12 gap-2">
|
||||
<div className="col-span-12 space-y-2 md:col-span-6">
|
||||
{patternTitle && (
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{patternTitle}
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-12 space-y-2 md:col-span-5">
|
||||
{replacementTitle && (
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{replacementTitle}
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule, index) => {
|
||||
const patternId = `${baseId}-${index}-pattern`;
|
||||
const replacementId = `${baseId}-${index}-replacement`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-12 items-start gap-2"
|
||||
>
|
||||
<div className="col-span-12 md:col-span-6">
|
||||
<Input
|
||||
id={patternId}
|
||||
value={rule?.pattern ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
onChange={(e) =>
|
||||
handleUpdate(index, { pattern: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-12 md:col-span-5">
|
||||
<Input
|
||||
id={replacementId}
|
||||
value={rule?.replacement ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
onChange={(e) =>
|
||||
handleUpdate(index, { replacement: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-12 flex items-center md:col-span-1 md:justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemove(index)}
|
||||
disabled={disabled || readonly}
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
disabled={disabled || readonly}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("button.add", { ns: "common", defaultValue: "Add" })}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReplaceRulesField;
|
||||
@@ -0,0 +1,4 @@
|
||||
// Custom RJSF Fields
|
||||
export { LayoutGridField } from "./LayoutGridField";
|
||||
export { DetectorHardwareField } from "./DetectorHardwareField";
|
||||
export { ReplaceRulesField } from "./ReplaceRulesField";
|
||||
@@ -0,0 +1,60 @@
|
||||
// Utilities for handling anyOf with null patterns
|
||||
import type { StrictRJSFSchema } from "@rjsf/utils";
|
||||
|
||||
/**
|
||||
* Checks if a schema is anyOf/oneOf with exactly [Type, null].
|
||||
* This indicates a nullable field in Pydantic schemas.
|
||||
*/
|
||||
export function isNullableUnionSchema(schema: StrictRJSFSchema): boolean {
|
||||
const union = schema.anyOf ?? schema.oneOf;
|
||||
if (!union || !Array.isArray(union) || union.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let hasNull = false;
|
||||
let nonNullCount = 0;
|
||||
|
||||
for (const item of union) {
|
||||
if (typeof item !== "object" || item === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const itemSchema = item as StrictRJSFSchema;
|
||||
|
||||
if (itemSchema.type === "null") {
|
||||
hasNull = true;
|
||||
} else {
|
||||
nonNullCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return hasNull && nonNullCount === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backwards-compatible alias for nullable fields
|
||||
*/
|
||||
export function isSimpleNullableField(schema: StrictRJSFSchema): boolean {
|
||||
return isNullableUnionSchema(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the non-null schema from an anyOf containing [Type, null]
|
||||
*/
|
||||
export function getNonNullSchema(
|
||||
schema: StrictRJSFSchema,
|
||||
): StrictRJSFSchema | null {
|
||||
const union = schema.anyOf ?? schema.oneOf;
|
||||
if (!union || !Array.isArray(union)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
(union.find(
|
||||
(item) =>
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
(item as StrictRJSFSchema).type !== "null",
|
||||
) as StrictRJSFSchema) || null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// 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 { SwitchWidget } from "./widgets/SwitchWidget";
|
||||
import { SelectWidget } from "./widgets/SelectWidget";
|
||||
import { TextWidget } from "./widgets/TextWidget";
|
||||
import { PasswordWidget } from "./widgets/PasswordWidget";
|
||||
import { RangeWidget } from "./widgets/RangeWidget";
|
||||
import { TagsWidget } from "./widgets/TagsWidget";
|
||||
import { ColorWidget } from "./widgets/ColorWidget";
|
||||
import { TextareaWidget } from "./widgets/TextareaWidget";
|
||||
import { SwitchesWidget } from "./widgets/SwitchesWidget";
|
||||
import { ObjectLabelSwitchesWidget } from "./widgets/ObjectLabelSwitchesWidget";
|
||||
import { AudioLabelSwitchesWidget } from "./widgets/AudioLabelSwitchesWidget";
|
||||
import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget";
|
||||
import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget";
|
||||
import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
|
||||
import { InputRolesWidget } from "./widgets/InputRolesWidget";
|
||||
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
||||
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
||||
|
||||
import { FieldTemplate } from "./templates/FieldTemplate";
|
||||
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
||||
import { ArrayFieldTemplate } from "./templates/ArrayFieldTemplate";
|
||||
import { ArrayFieldItemTemplate } from "./templates/ArrayFieldItemTemplate";
|
||||
import { BaseInputTemplate } from "./templates/BaseInputTemplate";
|
||||
import { DescriptionFieldTemplate } from "./templates/DescriptionFieldTemplate";
|
||||
import { TitleFieldTemplate } from "./templates/TitleFieldTemplate";
|
||||
import { ErrorListTemplate } from "./templates/ErrorListTemplate";
|
||||
import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate";
|
||||
import { WrapIfAdditionalTemplate } from "./templates/WrapIfAdditionalTemplate";
|
||||
|
||||
import { LayoutGridField } from "./fields/LayoutGridField";
|
||||
import { DetectorHardwareField } from "./fields/DetectorHardwareField";
|
||||
import { ReplaceRulesField } from "./fields/ReplaceRulesField";
|
||||
import { CameraInputsField } from "./fields/CameraInputsField";
|
||||
|
||||
export interface FrigateTheme {
|
||||
widgets: RegistryWidgetsType;
|
||||
templates: Partial<TemplatesType>;
|
||||
fields: RegistryFieldsType;
|
||||
}
|
||||
|
||||
export const frigateTheme: FrigateTheme = {
|
||||
widgets: {
|
||||
// Override default widgets with shadcn/ui styled versions
|
||||
TextWidget: TextWidget,
|
||||
PasswordWidget: PasswordWidget,
|
||||
SelectWidget: SelectWidget,
|
||||
CheckboxWidget: SwitchWidget,
|
||||
ArrayAsTextWidget: ArrayAsTextWidget,
|
||||
FfmpegArgsWidget: FfmpegArgsWidget,
|
||||
CameraPathWidget: CameraPathWidget,
|
||||
inputRoles: InputRolesWidget,
|
||||
// Custom widgets
|
||||
switch: SwitchWidget,
|
||||
password: PasswordWidget,
|
||||
select: SelectWidget,
|
||||
range: RangeWidget,
|
||||
tags: TagsWidget,
|
||||
color: ColorWidget,
|
||||
textarea: TextareaWidget,
|
||||
switches: SwitchesWidget,
|
||||
objectLabels: ObjectLabelSwitchesWidget,
|
||||
audioLabels: AudioLabelSwitchesWidget,
|
||||
zoneNames: ZoneSwitchesWidget,
|
||||
timezoneSelect: TimezoneSelectWidget,
|
||||
},
|
||||
templates: {
|
||||
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
|
||||
ObjectFieldTemplate: ObjectFieldTemplate,
|
||||
ArrayFieldTemplate: ArrayFieldTemplate,
|
||||
ArrayFieldItemTemplate: ArrayFieldItemTemplate,
|
||||
BaseInputTemplate: BaseInputTemplate as React.ComponentType<WidgetProps>,
|
||||
DescriptionFieldTemplate: DescriptionFieldTemplate,
|
||||
TitleFieldTemplate: TitleFieldTemplate,
|
||||
ErrorListTemplate: ErrorListTemplate,
|
||||
MultiSchemaFieldTemplate: MultiSchemaFieldTemplate,
|
||||
WrapIfAdditionalTemplate: WrapIfAdditionalTemplate,
|
||||
},
|
||||
fields: {
|
||||
LayoutGridField: LayoutGridField,
|
||||
DetectorHardwareField: DetectorHardwareField,
|
||||
ReplaceRulesField: ReplaceRulesField,
|
||||
CameraInputsField: CameraInputsField,
|
||||
},
|
||||
};
|
||||
@@ -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,58 @@
|
||||
import type {
|
||||
ArrayFieldItemTemplateProps,
|
||||
FormContextType,
|
||||
RJSFSchema,
|
||||
StrictRJSFSchema,
|
||||
} from "@rjsf/utils";
|
||||
import { getTemplate, getUiOptions } from "@rjsf/utils";
|
||||
|
||||
/**
|
||||
* Custom ArrayFieldItemTemplate to ensure array item content uses full width
|
||||
* while keeping action buttons aligned to the right.
|
||||
*/
|
||||
export function ArrayFieldItemTemplate<
|
||||
T = unknown,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = FormContextType,
|
||||
>(props: ArrayFieldItemTemplateProps<T, S, F>) {
|
||||
const {
|
||||
children,
|
||||
buttonsProps,
|
||||
displayLabel,
|
||||
hasDescription,
|
||||
hasToolbar,
|
||||
uiSchema,
|
||||
registry,
|
||||
} = props;
|
||||
|
||||
const uiOptions = getUiOptions<T, S, F>(uiSchema);
|
||||
const ArrayFieldItemButtonsTemplate = getTemplate<
|
||||
"ArrayFieldItemButtonsTemplate",
|
||||
T,
|
||||
S,
|
||||
F
|
||||
>("ArrayFieldItemButtonsTemplate", registry, uiOptions);
|
||||
|
||||
const margin = hasDescription ? -6 : 22;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-2 flex w-full flex-row items-start gap-2">
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
{hasToolbar && (
|
||||
<div
|
||||
className="flex shrink-0 items-start justify-end gap-2"
|
||||
style={{
|
||||
marginLeft: "5px",
|
||||
marginTop: displayLabel ? `${margin}px` : undefined,
|
||||
}}
|
||||
>
|
||||
<ArrayFieldItemButtonsTemplate {...buttonsProps} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ArrayFieldItemTemplate;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user