pynode-flow 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. pynode/__init__.py +37 -0
  2. pynode/__main__.py +9 -0
  3. pynode/_perf.py +65 -0
  4. pynode/_version.py +24 -0
  5. pynode/api/__init__.py +15 -0
  6. pynode/api/helpers.py +79 -0
  7. pynode/api/nodes.py +423 -0
  8. pynode/api/services.py +124 -0
  9. pynode/api/sse.py +58 -0
  10. pynode/api/uploads.py +59 -0
  11. pynode/api/workflows.py +466 -0
  12. pynode/config.py +93 -0
  13. pynode/install_nodes.py +143 -0
  14. pynode/main.py +118 -0
  15. pynode/node_registry.py +156 -0
  16. pynode/nodes/AutoSyncNode/auto_sync_node.py +151 -0
  17. pynode/nodes/BBoxMetricsNode/__init__.py +3 -0
  18. pynode/nodes/BBoxMetricsNode/bbox_metrics_node.py +250 -0
  19. pynode/nodes/BBoxMetricsNode/requirements.txt +1 -0
  20. pynode/nodes/BatchNode/__init__.py +3 -0
  21. pynode/nodes/BatchNode/batch_node.py +86 -0
  22. pynode/nodes/BatchNode/requirements.txt +1 -0
  23. pynode/nodes/CameraNode/__init__.py +4 -0
  24. pynode/nodes/CameraNode/camera_node.py +204 -0
  25. pynode/nodes/CameraNode/requirements.txt +2 -0
  26. pynode/nodes/ChangeNode/__init__.py +1 -0
  27. pynode/nodes/ChangeNode/change_node.py +255 -0
  28. pynode/nodes/ChangeNode/requirements.txt +1 -0
  29. pynode/nodes/ConfidenceFilterNode/__init__.py +4 -0
  30. pynode/nodes/ConfidenceFilterNode/confidence_filter_node.py +208 -0
  31. pynode/nodes/ConfidenceFilterNode/requirements.txt +1 -0
  32. pynode/nodes/CounterNode/__init__.py +3 -0
  33. pynode/nodes/CounterNode/counter_node.py +191 -0
  34. pynode/nodes/CropNode/__init__.py +4 -0
  35. pynode/nodes/CropNode/crop_node.py +317 -0
  36. pynode/nodes/CropNode/requirements.txt +2 -0
  37. pynode/nodes/DebugNode/__init__.py +4 -0
  38. pynode/nodes/DebugNode/debug_node.py +169 -0
  39. pynode/nodes/DebugNode/requirements.txt +1 -0
  40. pynode/nodes/DelayNode/delay_node.py +218 -0
  41. pynode/nodes/DelayNode/requirements.txt +1 -0
  42. pynode/nodes/DrawPredictionsNode/__init__.py +3 -0
  43. pynode/nodes/DrawPredictionsNode/draw_predictions_node.py +365 -0
  44. pynode/nodes/DrawPredictionsNode/requirements.txt +2 -0
  45. pynode/nodes/ErrorNode/error_node.py +94 -0
  46. pynode/nodes/ErrorNode/requirements.txt +1 -0
  47. pynode/nodes/FilterNode/__init__.py +3 -0
  48. pynode/nodes/FilterNode/filter_node.py +102 -0
  49. pynode/nodes/FilterNode/requirements.txt +1 -0
  50. pynode/nodes/FrameSourceNode/__init__.py +4 -0
  51. pynode/nodes/FrameSourceNode/frame_source_node.py +319 -0
  52. pynode/nodes/FrameSourceNode/requirements.txt +1 -0
  53. pynode/nodes/FunctionNode/__init__.py +3 -0
  54. pynode/nodes/FunctionNode/function_node.py +168 -0
  55. pynode/nodes/FunctionNode/requirements.txt +1 -0
  56. pynode/nodes/GateNode/gate_node.py +60 -0
  57. pynode/nodes/GateNode/requirements.txt +1 -0
  58. pynode/nodes/GetiUploadNode/__init__.py +3 -0
  59. pynode/nodes/GetiUploadNode/geti_upload_node.py +278 -0
  60. pynode/nodes/GetiUploadNode/requirements.txt +1 -0
  61. pynode/nodes/ImageFormatNode/__init__.py +4 -0
  62. pynode/nodes/ImageFormatNode/image_format_node.py +310 -0
  63. pynode/nodes/ImageFormatNode/requirements.txt +2 -0
  64. pynode/nodes/ImageUploadNode/image_upload_node.py +96 -0
  65. pynode/nodes/ImageViewerNode/__init__.py +4 -0
  66. pynode/nodes/ImageViewerNode/image_viewer_node.py +162 -0
  67. pynode/nodes/ImageViewerNode/requirements.txt +2 -0
  68. pynode/nodes/ImageWriterNode/__init__.py +3 -0
  69. pynode/nodes/ImageWriterNode/imagewriter_node.py +352 -0
  70. pynode/nodes/ImageWriterNode/requirements.txt +1 -0
  71. pynode/nodes/InferenceNode/InferenceEngine/__init__.py +15 -0
  72. pynode/nodes/InferenceNode/InferenceEngine/device_detection.py +149 -0
  73. pynode/nodes/InferenceNode/InferenceEngine/engines/base_engine.py +155 -0
  74. pynode/nodes/InferenceNode/InferenceEngine/engines/example_engine_template.py +272 -0
  75. pynode/nodes/InferenceNode/InferenceEngine/engines/geti_engine.py +309 -0
  76. pynode/nodes/InferenceNode/InferenceEngine/engines/onnx_engine.py +346 -0
  77. pynode/nodes/InferenceNode/InferenceEngine/engines/pass_engine.py +86 -0
  78. pynode/nodes/InferenceNode/InferenceEngine/engines/ultralytics_engine.py +863 -0
  79. pynode/nodes/InferenceNode/InferenceEngine/inference_engine_factory.py +533 -0
  80. pynode/nodes/InferenceNode/InferenceEngine/readme.md +107 -0
  81. pynode/nodes/InferenceNode/InferenceEngine/result_converters.py +121 -0
  82. pynode/nodes/InferenceNode/inference_node.py +451 -0
  83. pynode/nodes/InferenceNode/requirements.txt +13 -0
  84. pynode/nodes/InjectNode/inject_node.py +245 -0
  85. pynode/nodes/InjectNode/requirements.txt +1 -0
  86. pynode/nodes/JoinNode/__init__.py +3 -0
  87. pynode/nodes/JoinNode/join_node.py +156 -0
  88. pynode/nodes/JoinNode/requirements.txt +1 -0
  89. pynode/nodes/LabelFilterNode/__init__.py +3 -0
  90. pynode/nodes/LabelFilterNode/label_filter_node.py +188 -0
  91. pynode/nodes/LabelFilterNode/requirements.txt +1 -0
  92. pynode/nodes/MQTTNode/__init__.py +8 -0
  93. pynode/nodes/MQTTNode/mqtt_in_node.py +148 -0
  94. pynode/nodes/MQTTNode/mqtt_out_node.py +157 -0
  95. pynode/nodes/MQTTNode/mqtt_service.py +367 -0
  96. pynode/nodes/MQTTNode/requirements.txt +1 -0
  97. pynode/nodes/MessageWriterNode/__init__.py +7 -0
  98. pynode/nodes/MessageWriterNode/messagereader_node.py +568 -0
  99. pynode/nodes/MessageWriterNode/messagewriter_node.py +505 -0
  100. pynode/nodes/OmronCameraNode/__init__.py +3 -0
  101. pynode/nodes/OmronCameraNode/omron_camera_node.py +652 -0
  102. pynode/nodes/OmronCameraNode/requirements.txt +4 -0
  103. pynode/nodes/OpenCV/__init__.py +1 -0
  104. pynode/nodes/OpenCV/arithmetic_node.py +376 -0
  105. pynode/nodes/OpenCV/bitwise_node.py +127 -0
  106. pynode/nodes/OpenCV/blend_node.py +127 -0
  107. pynode/nodes/OpenCV/blob_detector_node.py +233 -0
  108. pynode/nodes/OpenCV/blur_node.py +141 -0
  109. pynode/nodes/OpenCV/color_space_node.py +139 -0
  110. pynode/nodes/OpenCV/colormap_node.py +262 -0
  111. pynode/nodes/OpenCV/contours_node.py +258 -0
  112. pynode/nodes/OpenCV/denormalize_coords_node.py +365 -0
  113. pynode/nodes/OpenCV/draw_node.py +312 -0
  114. pynode/nodes/OpenCV/edge_detector_node.py +177 -0
  115. pynode/nodes/OpenCV/equirectangular_node.py +447 -0
  116. pynode/nodes/OpenCV/fft_node.py +102 -0
  117. pynode/nodes/OpenCV/focus_estimation_node.py +189 -0
  118. pynode/nodes/OpenCV/frequency_filter_node.py +241 -0
  119. pynode/nodes/OpenCV/histogram_node.py +160 -0
  120. pynode/nodes/OpenCV/hough_circles_node.py +224 -0
  121. pynode/nodes/OpenCV/hough_lines_node.py +243 -0
  122. pynode/nodes/OpenCV/in_range_node.py +186 -0
  123. pynode/nodes/OpenCV/morphology_node.py +136 -0
  124. pynode/nodes/OpenCV/normalize_coords_node.py +344 -0
  125. pynode/nodes/OpenCV/paste_node.py +244 -0
  126. pynode/nodes/OpenCV/perspective_node.py +174 -0
  127. pynode/nodes/OpenCV/realsense_depth_node.py +253 -0
  128. pynode/nodes/OpenCV/requirements.txt +2 -0
  129. pynode/nodes/OpenCV/resize_node.py +175 -0
  130. pynode/nodes/OpenCV/rotate_node.py +159 -0
  131. pynode/nodes/OpenCV/template_match_node.py +227 -0
  132. pynode/nodes/OpenCV/threshold_node.py +167 -0
  133. pynode/nodes/PointInShapeNode/__init__.py +3 -0
  134. pynode/nodes/PointInShapeNode/point_in_shape_node.py +313 -0
  135. pynode/nodes/PointInShapeNode/requirements.txt +2 -0
  136. pynode/nodes/PolygonMetricsNode/__init__.py +3 -0
  137. pynode/nodes/PolygonMetricsNode/polygon_metrics_node.py +331 -0
  138. pynode/nodes/PolygonMetricsNode/requirements.txt +2 -0
  139. pynode/nodes/QueueLengthProbeNode/__init__.py +3 -0
  140. pynode/nodes/QueueLengthProbeNode/queue_length_probe_node.py +116 -0
  141. pynode/nodes/Qwen3VLMNode/__init__.py +3 -0
  142. pynode/nodes/Qwen3VLMNode/qwen3_vlm_node.py +519 -0
  143. pynode/nodes/Qwen3VLMNode/requirements.txt +5 -0
  144. pynode/nodes/RESTEndpointNode/__init__.py +3 -0
  145. pynode/nodes/RESTEndpointNode/rest_endpoint_node.py +491 -0
  146. pynode/nodes/RangeNode/__init__.py +3 -0
  147. pynode/nodes/RangeNode/range_node.py +115 -0
  148. pynode/nodes/RangeNode/requirements.txt +1 -0
  149. pynode/nodes/RateProbeNode/__init__.py +3 -0
  150. pynode/nodes/RateProbeNode/rate_probe_node.py +183 -0
  151. pynode/nodes/RateProbeNode/requirements.txt +1 -0
  152. pynode/nodes/RoboflowUploadNode/__init__.py +3 -0
  153. pynode/nodes/RoboflowUploadNode/requirements.txt +1 -0
  154. pynode/nodes/RoboflowUploadNode/roboflow_upload_node.py +229 -0
  155. pynode/nodes/SliceNode/README.md +149 -0
  156. pynode/nodes/SliceNode/__init__.py +5 -0
  157. pynode/nodes/SliceNode/merge_predictions_node.py +444 -0
  158. pynode/nodes/SliceNode/requirements.txt +3 -0
  159. pynode/nodes/SliceNode/slice_collector_node.py +370 -0
  160. pynode/nodes/SliceNode/slice_image_node.py +380 -0
  161. pynode/nodes/SplitNode/__init__.py +3 -0
  162. pynode/nodes/SplitNode/requirements.txt +1 -0
  163. pynode/nodes/SplitNode/split_node.py +155 -0
  164. pynode/nodes/SwitchNode/requirements.txt +1 -0
  165. pynode/nodes/SwitchNode/switch_node.py +259 -0
  166. pynode/nodes/SyncNode/__init__.py +3 -0
  167. pynode/nodes/SyncNode/sync_node.py +287 -0
  168. pynode/nodes/TemplateNode/requirements.txt +1 -0
  169. pynode/nodes/TemplateNode/template_node.py +86 -0
  170. pynode/nodes/TrackerNode/__init__.py +2 -0
  171. pynode/nodes/TrackerNode/supervision_tracker_node.py +248 -0
  172. pynode/nodes/TrackerNode/tracker_node.py +745 -0
  173. pynode/nodes/UltralyticsNode/__init__.py +4 -0
  174. pynode/nodes/UltralyticsNode/requirements.txt +2 -0
  175. pynode/nodes/UltralyticsNode/ultralytics_node.py +364 -0
  176. pynode/nodes/UnknownNode/__init__.py +93 -0
  177. pynode/nodes/VLLMNode/__init__.py +3 -0
  178. pynode/nodes/VLLMNode/vllm_node.py +345 -0
  179. pynode/nodes/VideoReaderNode/__init__.py +3 -0
  180. pynode/nodes/VideoReaderNode/video_reader_node.py +425 -0
  181. pynode/nodes/VideoWriterNode/__init__.py +3 -0
  182. pynode/nodes/VideoWriterNode/video_writer_node.py +441 -0
  183. pynode/nodes/WebhookNode/__init__.py +3 -0
  184. pynode/nodes/WebhookNode/webhook_node.py +591 -0
  185. pynode/nodes/__init__.py +109 -0
  186. pynode/nodes/base_node.py +562 -0
  187. pynode/nodes/image_utils.py +291 -0
  188. pynode/nodes/info.py +109 -0
  189. pynode/nodes/mDNSNode/__init__.py +13 -0
  190. pynode/nodes/mDNSNode/mdns_broadcast_node.py +214 -0
  191. pynode/nodes/mDNSNode/mdns_discovery_node.py +362 -0
  192. pynode/nodes/mDNSNode/mdns_manager.py +399 -0
  193. pynode/nodes/mDNSNode/requirements.txt +1 -0
  194. pynode/nodes/messages.py +81 -0
  195. pynode/server.py +187 -0
  196. pynode/static/images/favicon4.png +0 -0
  197. pynode/static/index.html +200 -0
  198. pynode/static/js/README.md +139 -0
  199. pynode/static/js/auth.js +89 -0
  200. pynode/static/js/clipboard.js +152 -0
  201. pynode/static/js/config.js +14 -0
  202. pynode/static/js/connections.js +844 -0
  203. pynode/static/js/debug.js +524 -0
  204. pynode/static/js/events.js +775 -0
  205. pynode/static/js/history.js +199 -0
  206. pynode/static/js/main.js +55 -0
  207. pynode/static/js/minimap.js +242 -0
  208. pynode/static/js/nodes.js +995 -0
  209. pynode/static/js/palette.js +255 -0
  210. pynode/static/js/properties.js +1270 -0
  211. pynode/static/js/selection.js +241 -0
  212. pynode/static/js/state.js +211 -0
  213. pynode/static/js/ui-utils.js +28 -0
  214. pynode/static/js/viewport.js +204 -0
  215. pynode/static/js/workflow.js +469 -0
  216. pynode/static/js/workflows.js +344 -0
  217. pynode/static/style.css +2751 -0
  218. pynode/workflow_engine.py +483 -0
  219. pynode/workflow_manager.py +440 -0
  220. pynode_flow-0.2.0.dist-info/METADATA +520 -0
  221. pynode_flow-0.2.0.dist-info/RECORD +227 -0
  222. pynode_flow-0.2.0.dist-info/WHEEL +5 -0
  223. pynode_flow-0.2.0.dist-info/entry_points.txt +3 -0
  224. pynode_flow-0.2.0.dist-info/licenses/LICENSE +21 -0
  225. pynode_flow-0.2.0.dist-info/scm_file_list.json +270 -0
  226. pynode_flow-0.2.0.dist-info/scm_version.json +8 -0
  227. pynode_flow-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1270 @@
1
+ // Properties panel
2
+ import { state, markNodeModified, setModified, getNodeType } from './state.js';
3
+ import { API_BASE } from './config.js';
4
+ import { updateNodeOutputCount, updateNodeInputCount } from './nodes.js';
5
+ import { updateConnections } from './connections.js';
6
+ import { showToast } from './ui-utils.js';
7
+
8
+ // Helper function to check if property should be shown
9
+ function shouldShowProperty(showIf, config) {
10
+ for (const [key, value] of Object.entries(showIf)) {
11
+ const configValue = config[key];
12
+
13
+ if (Array.isArray(value)) {
14
+ // Check if config value is in the array
15
+ if (!value.includes(configValue)) {
16
+ return false;
17
+ }
18
+ } else {
19
+ // Check if config value matches
20
+ if (configValue !== value) {
21
+ return false;
22
+ }
23
+ }
24
+ }
25
+ return true;
26
+ }
27
+
28
+ export function renderProperties(nodeData) {
29
+ const panel = document.getElementById('properties-panel');
30
+ const panelContainer = document.getElementById('properties-panel-container');
31
+ if (panelContainer) {
32
+ panelContainer.classList.remove('hidden');
33
+ }
34
+
35
+ const isEnabled = nodeData.enabled !== undefined ? nodeData.enabled : true;
36
+
37
+ let html = `
38
+ <div class="property-group">
39
+ <label class="property-label">Name</label>
40
+ <input type="text" class="property-input" value="${nodeData.name}"
41
+ onchange="window.updateNodeProperty('${nodeData.id}', 'name', this.value)">
42
+ </div>
43
+ <div class="property-group property-enabled-row">
44
+ <span class="property-label">Enabled</span>
45
+ <label class="gate-switch">
46
+ <input type="checkbox" ${isEnabled ? 'checked' : ''}
47
+ onchange="window.toggleNodeEnabled('${nodeData.id}', this.checked)">
48
+ <span class="gate-slider"></span>
49
+ </label>
50
+ </div>
51
+ `;
52
+
53
+ const nodeType = getNodeType(nodeData.type);
54
+ if (nodeType && nodeType.properties) {
55
+ nodeType.properties.forEach(prop => {
56
+ // Add property group with conditional visibility
57
+ const shouldShow = !prop.showIf || shouldShowProperty(prop.showIf, nodeData.config);
58
+
59
+ html += '<div class="property-group"';
60
+ // Add data attribute for dynamic visibility
61
+ if (prop.showIf) {
62
+ html += ` data-show-if='${JSON.stringify(prop.showIf)}'`;
63
+ }
64
+ // Hide initially if condition not met
65
+ if (!shouldShow) {
66
+ html += ' style="display: none;"';
67
+ }
68
+ html += '>';
69
+
70
+ if (prop.type === 'text') {
71
+ const value = nodeData.config[prop.name] !== undefined ? nodeData.config[prop.name] : (prop.default || '');
72
+ const placeholder = prop.placeholder || prop.default || '';
73
+ html += `
74
+ <label class="property-label">${prop.label}</label>
75
+ <input type="text" class="property-input"
76
+ value="${value}"
77
+ placeholder="${placeholder}"
78
+ onchange="window.updateNodeConfig('${nodeData.id}', '${prop.name}', this.value)">
79
+ `;
80
+ } else if (prop.type === 'number') {
81
+ const value = nodeData.config[prop.name] !== undefined ? nodeData.config[prop.name] : (prop.default || 0);
82
+ html += `
83
+ <label class="property-label">${prop.label}</label>
84
+ <input type="number" class="property-input"
85
+ value="${value}"
86
+ onchange="window.updateNodeConfig('${nodeData.id}', '${prop.name}', parseFloat(this.value))">
87
+ `;
88
+ } else if (prop.type === 'checkbox') {
89
+ const checked = nodeData.config[prop.name] !== undefined ? nodeData.config[prop.name] : (prop.default || false);
90
+ html += `
91
+ <label class="property-label">
92
+ <input type="checkbox" class="property-checkbox"
93
+ ${checked ? 'checked' : ''}
94
+ onchange="window.updateNodeConfig('${nodeData.id}', '${prop.name}', this.checked)">
95
+ ${prop.label}
96
+ </label>
97
+ `;
98
+ } else if (prop.type === 'textarea') {
99
+ const value = nodeData.config[prop.name] !== undefined ? nodeData.config[prop.name] : (prop.default || '');
100
+ const placeholder = prop.placeholder || prop.default || '';
101
+ html += `
102
+ <label class="property-label">${prop.label}</label>
103
+ <textarea class="property-input property-textarea"
104
+ placeholder="${placeholder}"
105
+ onchange="window.updateNodeConfig('${nodeData.id}', '${prop.name}', this.value)">${value}</textarea>
106
+ `;
107
+ } else if (prop.type === 'select') {
108
+ html += `
109
+ <label class="property-label">${prop.label}</label>
110
+ <select class="property-select"
111
+ onchange="window.updateNodeConfig('${nodeData.id}', '${prop.name}', this.value); window.updatePropertyVisibility('${nodeData.id}')">
112
+ `;
113
+ prop.options.forEach(option => {
114
+ const selected = nodeData.config[prop.name] === option.value ? 'selected' : '';
115
+ html += `<option value="${option.value}" ${selected}>${option.label}</option>`;
116
+ });
117
+ html += '</select>';
118
+ } else if (prop.type === 'button') {
119
+ html += `
120
+ <button class="btn btn-primary" onclick="window.triggerNodeAction('${nodeData.id}', '${prop.action}')">${prop.label}</button>
121
+ `;
122
+ } else if (prop.type === 'toggle') {
123
+ // Toggle switch with action - syncs with node's uiComponent toggle
124
+ const stateField = prop.stateField || 'drawingEnabled'; // Default for backward compat
125
+ const isChecked = nodeData[stateField] !== false; // Default to true if undefined
126
+ html += `
127
+ <div class="property-toggle-row">
128
+ <span class="property-label">${prop.label}</span>
129
+ <label class="gate-switch">
130
+ <input type="checkbox"
131
+ id="prop-toggle-${nodeData.id}-${prop.name}"
132
+ data-state-field="${stateField}"
133
+ ${isChecked ? 'checked' : ''}
134
+ onchange="window.triggerToggleAction('${nodeData.id}', '${prop.action}', '${stateField}', this.checked)">
135
+ <span class="gate-slider"></span>
136
+ </label>
137
+ </div>
138
+ `;
139
+ } else if (prop.type === 'file') {
140
+ const value = nodeData.config[prop.name] !== undefined ? nodeData.config[prop.name] : (prop.default || '');
141
+ const accept = prop.accept || '';
142
+ const uploadRoute = prop.uploadRoute || '';
143
+ const placeholder = prop.placeholder || 'Select or enter file path...';
144
+ const fileId = `file-${nodeData.id}-${prop.name}`;
145
+ html += `
146
+ <label class="property-label">${prop.label}</label>
147
+ <div class="property-file-container">
148
+ <input type="text" class="property-input property-file-path"
149
+ value="${value}"
150
+ placeholder="${placeholder}"
151
+ onchange="window.updateNodeConfig('${nodeData.id}', '${prop.name}', this.value)">
152
+ <button class="btn btn-secondary property-file-btn" onclick="window.selectFile('${nodeData.id}', '${prop.name}', '${accept}', '${uploadRoute}')">
153
+ <i class="fas fa-folder-open"></i>
154
+ </button>
155
+ </div>
156
+ <div class="upload-progress-container" id="${fileId}-progress" style="display:none;">
157
+ <div class="upload-progress-bar">
158
+ <div class="upload-progress-fill" id="${fileId}-fill"></div>
159
+ </div>
160
+ <span class="upload-progress-text" id="${fileId}-text">0%</span>
161
+ </div>
162
+ `;
163
+ } else if (prop.type === 'mqtt-service') {
164
+ // MQTT Service selector - like Node-RED config nodes
165
+ const currentServiceId = nodeData.config[prop.name] || '';
166
+ html += `
167
+ <label class="property-label">${prop.label}</label>
168
+ <div class="property-service-container">
169
+ <select class="property-select property-service-select"
170
+ id="mqtt-service-${nodeData.id}"
171
+ data-node-id="${nodeData.id}"
172
+ data-prop-name="${prop.name}"
173
+ onchange="window.onMqttServiceSelect('${nodeData.id}', '${prop.name}', this.value)">
174
+ <option value="">-- Select broker --</option>
175
+ </select>
176
+ <button class="btn btn-secondary property-service-btn"
177
+ onclick="window.openMqttServiceEditor('${nodeData.id}', '${prop.name}')"
178
+ title="Edit broker settings">
179
+ ✏️
180
+ </button>
181
+ <button class="btn btn-primary property-service-btn"
182
+ onclick="window.openMqttServiceEditor('${nodeData.id}', '${prop.name}', true)"
183
+ title="Add new broker">
184
+ +
185
+ </button>
186
+ </div>
187
+ `;
188
+ // Load services after rendering
189
+ setTimeout(() => window.loadMqttServices(nodeData.id, prop.name, currentServiceId), 0);
190
+ } else if (prop.type === 'rules') {
191
+ html += renderRulesEditor(nodeData.id, prop.name, nodeData.config[prop.name] || []);
192
+ } else if (prop.type === 'injectProps') {
193
+ html += renderInjectPropsEditor(nodeData.id, prop.name, nodeData.config[prop.name] || []);
194
+ } else if (prop.type === 'changeRules') {
195
+ html += renderChangeRulesEditor(nodeData.id, prop.name, nodeData.config[prop.name] || []);
196
+ } else if (prop.type === 'streamUrl') {
197
+ // Read-only stream URL that opens in new tab
198
+ const streamUrl = `${window.location.origin}/api/nodes/${nodeData.id}/stream`;
199
+ html += `
200
+ <label class="property-label">${prop.label}</label>
201
+ <div class="property-stream-url-container">
202
+ <input type="text" class="property-input property-stream-url"
203
+ value="${streamUrl}" readonly
204
+ onclick="this.select()">
205
+ <button class="btn btn-secondary property-stream-btn"
206
+ onclick="window.open('${streamUrl}', '_blank')"
207
+ title="Open stream in new tab">
208
+ ↗️
209
+ </button>
210
+ <button class="btn btn-secondary property-stream-btn"
211
+ onclick="navigator.clipboard.writeText('${streamUrl}'); window.showToast && window.showToast('URL copied!')"
212
+ title="Copy URL">
213
+ 🗐
214
+ </button>
215
+ </div>
216
+ `;
217
+ } else if (prop.type === 'multiselect') {
218
+ // Multiselect - allows selecting multiple options with checkboxes
219
+ const selectedValues = nodeData.config[prop.name] || prop.default || [];
220
+ html += `<label class="property-label">${prop.label}</label>`;
221
+ html += `<div class="property-multiselect" data-prop="${prop.name}">`;
222
+
223
+ prop.options.forEach(option => {
224
+ const optValue = typeof option === 'object' ? option.value : option;
225
+ const optLabel = typeof option === 'object' ? option.label : option;
226
+ const isChecked = Array.isArray(selectedValues) ? selectedValues.includes(optValue) : selectedValues === optValue;
227
+
228
+ html += `
229
+ <label class="multiselect-option">
230
+ <input type="checkbox"
231
+ value="${optValue}"
232
+ ${isChecked ? 'checked' : ''}
233
+ onchange="window.updateMultiselectConfig('${nodeData.id}', '${prop.name}', this)">
234
+ <span>${optLabel}</span>
235
+ </label>
236
+ `;
237
+ });
238
+
239
+ html += '</div>';
240
+ }
241
+
242
+ if (prop.help) {
243
+ html += `<small class="property-help">${prop.help}</small>`;
244
+ }
245
+
246
+ html += '</div>';
247
+ });
248
+ }
249
+
250
+ panel.innerHTML = html;
251
+
252
+ // Update property visibility based on current config
253
+ window.updatePropertyVisibility(nodeData.id);
254
+ }
255
+
256
+ export function updateNodeProperty(nodeId, property, value) {
257
+ const nodeData = state.nodes.get(nodeId);
258
+ nodeData[property] = value;
259
+
260
+ const nodeEl = document.getElementById(`node-${nodeId}`);
261
+ nodeEl.querySelector('.node-title').textContent = value;
262
+
263
+ markNodeModified(nodeId);
264
+ setModified(true);
265
+ }
266
+
267
+ export function updateNodeConfig(nodeId, key, value) {
268
+ const nodeData = state.nodes.get(nodeId);
269
+ nodeData.config[key] = value;
270
+
271
+ // If outputs property changed, update the node's output ports
272
+ if (key === 'outputs') {
273
+ updateNodeOutputCount(nodeId, parseInt(value, 10));
274
+ }
275
+
276
+ // If input_count property changed, update the node's input ports
277
+ if (key === 'input_count') {
278
+ updateNodeInputCount(nodeId, parseInt(value, 10));
279
+ }
280
+
281
+ // Update property visibility since config changed
282
+ window.updatePropertyVisibility(nodeId);
283
+
284
+ markNodeModified(nodeId);
285
+ setModified(true);
286
+ }
287
+
288
+ /**
289
+ * Update multiselect config - collects all checked values into an array
290
+ */
291
+ export function updateMultiselectConfig(nodeId, propName, checkbox) {
292
+ const nodeData = state.nodes.get(nodeId);
293
+ if (!nodeData) return;
294
+
295
+ // Get the parent multiselect container
296
+ const container = checkbox.closest('.property-multiselect');
297
+ if (!container) return;
298
+
299
+ // Collect all checked values
300
+ const checkedValues = [];
301
+ container.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => {
302
+ checkedValues.push(cb.value);
303
+ });
304
+
305
+ nodeData.config[propName] = checkedValues;
306
+
307
+ // Update property visibility since config changed
308
+ window.updatePropertyVisibility(nodeId);
309
+
310
+ markNodeModified(nodeId);
311
+ setModified(true);
312
+ }
313
+
314
+ // Make it available globally
315
+ window.updateMultiselectConfig = updateMultiselectConfig;
316
+
317
+ export async function triggerNodeAction(nodeId, action) {
318
+ try {
319
+ await fetch(`${API_BASE}/nodes/${nodeId}/${action}`, { method: 'POST' });
320
+ } catch (error) {
321
+ console.error(`Failed to trigger ${action} on node:`, error);
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Trigger a toggle action and sync state between properties panel and node UI.
327
+ * @param {string} nodeId - The node ID
328
+ * @param {string} action - The action to call (e.g., 'toggle_drawing')
329
+ * @param {string} stateField - The field in nodeData to update (e.g., 'drawingEnabled')
330
+ * @param {boolean} checked - The new toggle state
331
+ */
332
+ export async function triggerToggleAction(nodeId, action, stateField, checked) {
333
+ try {
334
+ // Call the action endpoint
335
+ const response = await fetch(`${API_BASE}/nodes/${nodeId}/${action}`, { method: 'POST' });
336
+
337
+ if (response.ok) {
338
+ // Update nodeData state
339
+ const nodeData = state.nodes.get(nodeId);
340
+ if (nodeData) {
341
+ nodeData[stateField] = checked;
342
+ }
343
+
344
+ // Sync the toggle on the node itself (if it has one)
345
+ const nodeToggle = document.getElementById(`toggle-${nodeId}`);
346
+ if (nodeToggle && nodeToggle.checked !== checked) {
347
+ nodeToggle.checked = checked;
348
+ }
349
+ }
350
+ } catch (error) {
351
+ console.error(`Failed to trigger ${action} on node:`, error);
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Generic function to toggle node enabled state.
357
+ * Consolidates toggleGate, toggleDebug, and toggleNodeEnabled into one function.
358
+ * @param {string} nodeId - The node ID
359
+ * @param {boolean} enabled - The new enabled state
360
+ * @param {Object} options - Optional configuration
361
+ * @param {string} options.checkboxSelector - CSS selector for checkbox to sync (e.g., '#gate-{nodeId}')
362
+ */
363
+ export async function toggleNodeState(nodeId, enabled, options = {}) {
364
+ try {
365
+ const response = await fetch(`${API_BASE}/nodes/${nodeId}/enabled`, {
366
+ method: 'POST',
367
+ headers: { 'Content-Type': 'application/json' },
368
+ body: JSON.stringify({ enabled: enabled })
369
+ });
370
+
371
+ if (!response.ok) {
372
+ throw new Error(`HTTP ${response.status}`);
373
+ }
374
+
375
+ const nodeData = state.nodes.get(nodeId);
376
+ if (nodeData) {
377
+ nodeData.enabled = enabled;
378
+
379
+ // Update node visual state
380
+ const nodeEl = document.getElementById(`node-${nodeId}`);
381
+ if (nodeEl) {
382
+ nodeEl.classList.toggle('disabled', !enabled);
383
+ }
384
+
385
+ // Update connections (dashed when disabled)
386
+ updateConnections();
387
+
388
+ // Update any visual toggle switches on the node (DebugNode, GateNode)
389
+ const gateCheckbox = document.getElementById(`gate-${nodeId}`);
390
+ const debugCheckbox = document.getElementById(`debug-${nodeId}`);
391
+ if (gateCheckbox) gateCheckbox.checked = enabled;
392
+ if (debugCheckbox) debugCheckbox.checked = enabled;
393
+
394
+ // Sync properties panel if this node is selected
395
+ if (state.selectedNode === nodeId) {
396
+ const propsToggle = document.querySelector('#properties-panel .gate-switch input');
397
+ if (propsToggle) propsToggle.checked = enabled;
398
+ }
399
+ }
400
+ } catch (error) {
401
+ console.error('Failed to toggle node state:', error);
402
+ }
403
+ }
404
+
405
+ // Backwards compatibility aliases
406
+ export const toggleGate = toggleNodeState;
407
+ export const toggleNodeEnabled = toggleNodeState;
408
+
409
+ /**
410
+ * Opens a file dialog to select a file and updates the node config.
411
+ * Uses XMLHttpRequest for upload progress tracking.
412
+ * @param {string} nodeId - The node ID
413
+ * @param {string} propName - The property name to update
414
+ * @param {string} accept - Comma-separated list of accepted file extensions
415
+ * @param {string} uploadRoute - Optional node-specific upload route (e.g. 'upload_video')
416
+ */
417
+ export function selectFile(nodeId, propName, accept, uploadRoute) {
418
+ const input = document.createElement('input');
419
+ input.type = 'file';
420
+ if (accept) {
421
+ input.accept = accept;
422
+ }
423
+
424
+ input.onchange = (e) => {
425
+ const file = e.target.files[0];
426
+ if (!file) return;
427
+
428
+ const formData = new FormData();
429
+ formData.append('file', file);
430
+
431
+ let url;
432
+ if (uploadRoute) {
433
+ url = `${API_BASE}/nodes/${nodeId}/${uploadRoute}`;
434
+ } else {
435
+ formData.append('nodeId', nodeId);
436
+ url = `${API_BASE}/upload/file`;
437
+ }
438
+
439
+ // Progress bar elements
440
+ const fileId = `file-${nodeId}-${propName}`;
441
+ const progressContainer = document.getElementById(`${fileId}-progress`);
442
+ const progressFill = document.getElementById(`${fileId}-fill`);
443
+ const progressText = document.getElementById(`${fileId}-text`);
444
+
445
+ const xhr = new XMLHttpRequest();
446
+
447
+ xhr.upload.addEventListener('progress', (evt) => {
448
+ if (evt.lengthComputable && progressContainer) {
449
+ const pct = Math.round((evt.loaded / evt.total) * 100);
450
+ progressContainer.style.display = '';
451
+ progressFill.style.width = `${pct}%`;
452
+ progressText.textContent = `${pct}%`;
453
+ }
454
+ });
455
+
456
+ xhr.addEventListener('load', () => {
457
+ // Hide progress bar
458
+ if (progressContainer) progressContainer.style.display = 'none';
459
+
460
+ if (xhr.status >= 200 && xhr.status < 300) {
461
+ try {
462
+ const result = JSON.parse(xhr.responseText);
463
+ if (result.success) {
464
+ const filePath = result.file_path || result.model_path;
465
+ updateNodeConfig(nodeId, propName, filePath);
466
+
467
+ const nodeData = state.nodes.get(nodeId);
468
+ renderProperties(nodeData);
469
+
470
+ showToast(`Uploaded: ${file.name}`);
471
+ } else {
472
+ showToast(`Upload failed: ${result.error}`, 'error');
473
+ }
474
+ } catch (parseErr) {
475
+ showToast(`Upload failed: invalid server response`, 'error');
476
+ }
477
+ } else {
478
+ let errorMsg = `Upload failed (${xhr.status})`;
479
+ try {
480
+ const errResult = JSON.parse(xhr.responseText);
481
+ errorMsg = errResult.error || errResult.message || errorMsg;
482
+ } catch (_) { /* use default */ }
483
+ showToast(errorMsg, 'error');
484
+ }
485
+ });
486
+
487
+ xhr.addEventListener('error', () => {
488
+ if (progressContainer) progressContainer.style.display = 'none';
489
+ showToast('Upload failed: network error', 'error');
490
+ });
491
+
492
+ xhr.addEventListener('abort', () => {
493
+ if (progressContainer) progressContainer.style.display = 'none';
494
+ });
495
+
496
+ xhr.open('POST', url);
497
+ xhr.send(formData);
498
+ };
499
+
500
+ input.click();
501
+ }
502
+
503
+ // Rules editor for switch node
504
+ function renderRulesEditor(nodeId, propName, rules) {
505
+ let html = '<div class="rules-editor">';
506
+
507
+ rules.forEach((rule, index) => {
508
+ html += `
509
+ <div class="rule-item" data-rule-index="${index}">
510
+ <div class="rule-header">
511
+ <span class="rule-label">Rule ${index + 1} → Output ${index + 1}</span>
512
+ <button class="btn-icon" onclick="window.removeRule('${nodeId}', '${propName}', ${index})" title="Delete rule">✕</button>
513
+ </div>
514
+ <div class="rule-config">
515
+ <select class="rule-operator" onchange="window.updateRule('${nodeId}', '${propName}', ${index}, 'operator', this.value)">
516
+ ${getOperatorOptions(rule.operator || 'eq')}
517
+ </select>
518
+ <input type="text" class="rule-value" placeholder="Value"
519
+ value="${rule.value || ''}"
520
+ onchange="window.updateRule('${nodeId}', '${propName}', ${index}, 'value', this.value)">
521
+ <select class="rule-type" onchange="window.updateRule('${nodeId}', '${propName}', ${index}, 'valueType', this.value)">
522
+ ${getValueTypeOptions(rule.valueType || 'str')}
523
+ </select>
524
+ </div>
525
+ </div>
526
+ `;
527
+ });
528
+
529
+ html += `
530
+ <button class="btn btn-secondary btn-sm" onclick="window.addRule('${nodeId}', '${propName}')">+ Add Rule</button>
531
+ </div>`;
532
+
533
+ return html;
534
+ }
535
+
536
+ function getOperatorOptions(selected) {
537
+ const operators = [
538
+ { value: 'eq', label: '==' },
539
+ { value: 'neq', label: '!=' },
540
+ { value: 'lt', label: '<' },
541
+ { value: 'lte', label: '<=' },
542
+ { value: 'gt', label: '>' },
543
+ { value: 'gte', label: '>=' },
544
+ { value: 'between', label: 'between' },
545
+ { value: 'contains', label: 'contains' },
546
+ { value: 'matches', label: 'matches regex' },
547
+ { value: 'true', label: 'is true' },
548
+ { value: 'false', label: 'is false' },
549
+ { value: 'null', label: 'is null' },
550
+ { value: 'nnull', label: 'is not null' },
551
+ { value: 'empty', label: 'is empty' },
552
+ { value: 'nempty', label: 'is not empty' },
553
+ { value: 'haskey', label: 'has key' },
554
+ { value: 'else', label: 'otherwise' }
555
+ ];
556
+
557
+ return operators.map(op =>
558
+ `<option value="${op.value}" ${op.value === selected ? 'selected' : ''}>${op.label}</option>`
559
+ ).join('');
560
+ }
561
+
562
+ function getValueTypeOptions(selected) {
563
+ const types = [
564
+ { value: 'str', label: 'string' },
565
+ { value: 'num', label: 'number' },
566
+ { value: 'bool', label: 'boolean' },
567
+ { value: 'json', label: 'JSON' }
568
+ ];
569
+
570
+ return types.map(type =>
571
+ `<option value="${type.value}" ${type.value === selected ? 'selected' : ''}>${type.label}</option>`
572
+ ).join('');
573
+ }
574
+
575
+ export function addRule(nodeId, propName) {
576
+ const nodeData = state.nodes.get(nodeId);
577
+ const rules = nodeData.config[propName] || [];
578
+
579
+ rules.push({
580
+ operator: 'eq',
581
+ value: '',
582
+ valueType: 'str'
583
+ });
584
+
585
+ nodeData.config[propName] = rules;
586
+
587
+ // Update output count to match rules
588
+ updateNodeOutputCount(nodeId, rules.length);
589
+
590
+ markNodeModified(nodeId);
591
+ setModified(true);
592
+ renderProperties(nodeData);
593
+ }
594
+
595
+ export function removeRule(nodeId, propName, ruleIndex) {
596
+ const nodeData = state.nodes.get(nodeId);
597
+ const rules = nodeData.config[propName] || [];
598
+
599
+ if (rules.length > 1) {
600
+ rules.splice(ruleIndex, 1);
601
+ nodeData.config[propName] = rules;
602
+
603
+ // Update output count to match rules
604
+ updateNodeOutputCount(nodeId, rules.length);
605
+
606
+ markNodeModified(nodeId);
607
+ setModified(true);
608
+ renderProperties(nodeData);
609
+ }
610
+ }
611
+
612
+ export function updateRule(nodeId, propName, ruleIndex, field, value) {
613
+ const nodeData = state.nodes.get(nodeId);
614
+ const rules = nodeData.config[propName] || [];
615
+
616
+ if (rules[ruleIndex]) {
617
+ rules[ruleIndex][field] = value;
618
+ nodeData.config[propName] = rules;
619
+
620
+ markNodeModified(nodeId);
621
+ setModified(true);
622
+ }
623
+ }
624
+
625
+ // Inject properties editor for inject node
626
+ function renderInjectPropsEditor(nodeId, propName, props) {
627
+ let html = '<div class="inject-props-editor">';
628
+
629
+ props.forEach((prop, index) => {
630
+ html += `
631
+ <div class="inject-prop-row" data-prop-index="${index}">
632
+ <span class="inject-prop-prefix">msg.</span>
633
+ <input type="text" class="inject-prop-key" placeholder="payload"
634
+ value="${prop.property || ''}"
635
+ onchange="window.updateInjectProp('${nodeId}', '${propName}', ${index}, 'property', this.value)">
636
+ <span class="inject-prop-eq">=</span>
637
+ <select class="inject-prop-type" onchange="window.updateInjectProp('${nodeId}', '${propName}', ${index}, 'valueType', this.value)">
638
+ ${getInjectValueTypeOptions(prop.valueType || 'str')}
639
+ </select>
640
+ ${renderInjectValueInput(nodeId, propName, index, prop)}
641
+ <button class="btn-icon-sm" onclick="window.removeInjectProp('${nodeId}', '${propName}', ${index})" title="Delete">✕</button>
642
+ </div>
643
+ `;
644
+ });
645
+
646
+ html += `
647
+ <button class="btn btn-secondary btn-sm" onclick="window.addInjectProp('${nodeId}', '${propName}')">+ Add</button>
648
+ </div>`;
649
+
650
+ return html;
651
+ }
652
+
653
+ function getInjectValueTypeOptions(selected) {
654
+ const types = [
655
+ { value: 'str', icon: 'az', label: 'string' },
656
+ { value: 'num', icon: '123', label: 'number' },
657
+ { value: 'bool', icon: 't/f', label: 'boolean' },
658
+ { value: 'json', icon: '{ }', label: 'JSON' },
659
+ { value: 'date', icon: '⏱', label: 'timestamp' },
660
+ { value: 'env', icon: 'env', label: 'env variable' }
661
+ ];
662
+
663
+ return types.map(type =>
664
+ `<option value="${type.value}" ${type.value === selected ? 'selected' : ''} data-icon="${type.icon}">${type.value === selected ? type.icon : type.label}</option>`
665
+ ).join('');
666
+ }
667
+
668
+ function renderInjectValueInput(nodeId, propName, index, prop) {
669
+ const valueType = prop.valueType || 'str';
670
+ const value = prop.value !== undefined ? prop.value : '';
671
+
672
+ if (valueType === 'date') {
673
+ return '<input type="text" class="inject-prop-value" disabled placeholder="timestamp">';
674
+ } else if (valueType === 'bool') {
675
+ return `<select class="inject-prop-value" onchange="window.updateInjectProp('${nodeId}', '${propName}', ${index}, 'value', this.value)"><option value="true" ${value === 'true' || value === true ? 'selected' : ''}>true</option><option value="false" ${value === 'false' || value === false ? 'selected' : ''}>false</option></select>`;
676
+ } else if (valueType === 'json') {
677
+ const escaped = String(value).replace(/"/g, '&quot;');
678
+ return `<input type="text" class="inject-prop-value inject-prop-json" placeholder='{"key":"value"}' value="${escaped}" onchange="window.updateInjectProp('${nodeId}', '${propName}', ${index}, 'value', this.value)">`;
679
+ } else {
680
+ const placeholder = valueType === 'num' ? '0' : valueType === 'env' ? 'ENV_VAR' : '';
681
+ return `<input type="text" class="inject-prop-value" placeholder="${placeholder}" value="${value}" onchange="window.updateInjectProp('${nodeId}', '${propName}', ${index}, 'value', this.value)">`;
682
+ }
683
+ }
684
+
685
+ export function addInjectProp(nodeId, propName) {
686
+ const nodeData = state.nodes.get(nodeId);
687
+ const props = nodeData.config[propName] || [];
688
+
689
+ props.push({
690
+ property: 'payload',
691
+ valueType: 'date',
692
+ value: ''
693
+ });
694
+
695
+ nodeData.config[propName] = props;
696
+
697
+ markNodeModified(nodeId);
698
+ setModified(true);
699
+ renderProperties(nodeData);
700
+ }
701
+
702
+ export function removeInjectProp(nodeId, propName, propIndex) {
703
+ const nodeData = state.nodes.get(nodeId);
704
+ const props = nodeData.config[propName] || [];
705
+
706
+ if (props.length > 0) {
707
+ props.splice(propIndex, 1);
708
+ nodeData.config[propName] = props;
709
+
710
+ markNodeModified(nodeId);
711
+ setModified(true);
712
+ renderProperties(nodeData);
713
+ }
714
+ }
715
+
716
+ export function updateInjectProp(nodeId, propName, propIndex, field, value) {
717
+ const nodeData = state.nodes.get(nodeId);
718
+ const props = nodeData.config[propName] || [];
719
+
720
+ if (props[propIndex]) {
721
+ props[propIndex][field] = value;
722
+ nodeData.config[propName] = props;
723
+
724
+ // Re-render if type changed to update value input
725
+ if (field === 'valueType') {
726
+ renderProperties(nodeData);
727
+ }
728
+
729
+ markNodeModified(nodeId);
730
+ setModified(true);
731
+ }
732
+ }
733
+
734
+ // Change rules editor for change node (Node-RED style)
735
+ function renderChangeRulesEditor(nodeId, propName, rules) {
736
+ let html = '<div class="change-rules-editor">';
737
+
738
+ rules.forEach((rule, index) => {
739
+ html += renderChangeRuleItem(nodeId, propName, rule, index);
740
+ });
741
+
742
+ html += `
743
+ <button class="btn btn-secondary btn-sm" onclick="window.addChangeRule('${nodeId}', '${propName}')">+ Add Rule</button>
744
+ </div>`;
745
+
746
+ return html;
747
+ }
748
+
749
+ function renderChangeRuleItem(nodeId, propName, rule, index) {
750
+ const ruleType = rule.type || 'set';
751
+ const path = rule.path || 'msg.payload';
752
+ const value = rule.value !== undefined ? rule.value : '';
753
+ const valueType = rule.valueType || 'str';
754
+ const search = rule.search || '';
755
+ const replace = rule.replace || '';
756
+ const searchType = rule.searchType || 'str';
757
+ const replaceType = rule.replaceType || 'str';
758
+
759
+ let html = `
760
+ <div class="change-rule-item" data-rule-index="${index}">
761
+ <div class="change-rule-header">
762
+ <select class="change-rule-type" onchange="window.updateChangeRule('${nodeId}', '${propName}', ${index}, 'type', this.value)">
763
+ ${getChangeRuleTypeOptions(ruleType)}
764
+ </select>
765
+ <button class="btn-icon-sm" onclick="window.removeChangeRule('${nodeId}', '${propName}', ${index})" title="Delete rule">✕</button>
766
+ </div>
767
+ <div class="change-rule-config">
768
+ `;
769
+
770
+ if (ruleType === 'set') {
771
+ html += `
772
+ <div class="change-rule-row">
773
+ <input type="text" class="change-rule-path" placeholder="msg.payload"
774
+ value="${path}"
775
+ onchange="window.updateChangeRule('${nodeId}', '${propName}', ${index}, 'path', this.value)">
776
+ <span class="change-rule-to">to</span>
777
+ <select class="change-rule-value-type" onchange="window.updateChangeRule('${nodeId}', '${propName}', ${index}, 'valueType', this.value)">
778
+ ${getChangeValueTypeOptions(valueType)}
779
+ </select>
780
+ ${renderChangeValueInput(nodeId, propName, index, value, valueType)}
781
+ </div>
782
+ `;
783
+ } else if (ruleType === 'change') {
784
+ html += `
785
+ <div class="change-rule-row">
786
+ <span class="change-rule-label">in</span>
787
+ <input type="text" class="change-rule-path" placeholder="msg.payload"
788
+ value="${path}"
789
+ onchange="window.updateChangeRule('${nodeId}', '${propName}', ${index}, 'path', this.value)">
790
+ </div>
791
+ <div class="change-rule-row">
792
+ <span class="change-rule-label">search</span>
793
+ <select class="change-rule-search-type" onchange="window.updateChangeRule('${nodeId}', '${propName}', ${index}, 'searchType', this.value)">
794
+ ${getChangeSearchTypeOptions(searchType)}
795
+ </select>
796
+ <input type="text" class="change-rule-search" placeholder="search"
797
+ value="${escapeHtml(search)}"
798
+ onchange="window.updateChangeRule('${nodeId}', '${propName}', ${index}, 'search', this.value)">
799
+ </div>
800
+ <div class="change-rule-row">
801
+ <span class="change-rule-label">replace</span>
802
+ <select class="change-rule-replace-type" onchange="window.updateChangeRule('${nodeId}', '${propName}', ${index}, 'replaceType', this.value)">
803
+ ${getChangeReplaceTypeOptions(replaceType)}
804
+ </select>
805
+ <input type="text" class="change-rule-replace" placeholder="replace"
806
+ value="${escapeHtml(replace)}"
807
+ onchange="window.updateChangeRule('${nodeId}', '${propName}', ${index}, 'replace', this.value)">
808
+ </div>
809
+ `;
810
+ } else if (ruleType === 'delete') {
811
+ html += `
812
+ <div class="change-rule-row">
813
+ <input type="text" class="change-rule-path" placeholder="msg.payload"
814
+ value="${path}"
815
+ onchange="window.updateChangeRule('${nodeId}', '${propName}', ${index}, 'path', this.value)">
816
+ </div>
817
+ `;
818
+ } else if (ruleType === 'move') {
819
+ const toPath = rule.toPath || '';
820
+ html += `
821
+ <div class="change-rule-row">
822
+ <input type="text" class="change-rule-path" placeholder="msg.payload"
823
+ value="${path}"
824
+ onchange="window.updateChangeRule('${nodeId}', '${propName}', ${index}, 'path', this.value)">
825
+ <span class="change-rule-to">to</span>
826
+ <input type="text" class="change-rule-to-path" placeholder="msg.newPayload"
827
+ value="${toPath}"
828
+ onchange="window.updateChangeRule('${nodeId}', '${propName}', ${index}, 'toPath', this.value)">
829
+ </div>
830
+ `;
831
+ }
832
+
833
+ html += `
834
+ </div>
835
+ </div>
836
+ `;
837
+
838
+ return html;
839
+ }
840
+
841
+ function escapeHtml(str) {
842
+ if (str === null || str === undefined) return '';
843
+ return String(str)
844
+ .replace(/&/g, '&amp;')
845
+ .replace(/</g, '&lt;')
846
+ .replace(/>/g, '&gt;')
847
+ .replace(/"/g, '&quot;')
848
+ .replace(/'/g, '&#039;');
849
+ }
850
+
851
+ function getChangeRuleTypeOptions(selected) {
852
+ const types = [
853
+ { value: 'set', label: 'Set' },
854
+ { value: 'change', label: 'Change' },
855
+ { value: 'delete', label: 'Delete' },
856
+ { value: 'move', label: 'Move' }
857
+ ];
858
+
859
+ return types.map(type =>
860
+ `<option value="${type.value}" ${type.value === selected ? 'selected' : ''}>${type.label}</option>`
861
+ ).join('');
862
+ }
863
+
864
+ function getChangeValueTypeOptions(selected) {
865
+ const types = [
866
+ { value: 'str', label: 'string' },
867
+ { value: 'num', label: 'number' },
868
+ { value: 'bool', label: 'boolean' },
869
+ { value: 'json', label: 'JSON' },
870
+ { value: 'path', label: 'msg. path' },
871
+ { value: 'date', label: 'timestamp' },
872
+ { value: 'env', label: 'env var' }
873
+ ];
874
+
875
+ return types.map(type =>
876
+ `<option value="${type.value}" ${type.value === selected ? 'selected' : ''}>${type.label}</option>`
877
+ ).join('');
878
+ }
879
+
880
+ function getChangeSearchTypeOptions(selected) {
881
+ const types = [
882
+ { value: 'str', label: 'string' },
883
+ { value: 'regex', label: 'regex' }
884
+ ];
885
+
886
+ return types.map(type =>
887
+ `<option value="${type.value}" ${type.value === selected ? 'selected' : ''}>${type.label}</option>`
888
+ ).join('');
889
+ }
890
+
891
+ function getChangeReplaceTypeOptions(selected) {
892
+ const types = [
893
+ { value: 'str', label: 'string' },
894
+ { value: 'path', label: 'msg. path' }
895
+ ];
896
+
897
+ return types.map(type =>
898
+ `<option value="${type.value}" ${type.value === selected ? 'selected' : ''}>${type.label}</option>`
899
+ ).join('');
900
+ }
901
+
902
+ function renderChangeValueInput(nodeId, propName, index, value, valueType) {
903
+ const escaped = escapeHtml(value);
904
+
905
+ if (valueType === 'bool') {
906
+ const boolVal = value === true || value === 'true';
907
+ return `<select class="change-rule-value" onchange="window.updateChangeRule('${nodeId}', '${propName}', ${index}, 'value', this.value === 'true')">
908
+ <option value="true" ${boolVal ? 'selected' : ''}>true</option>
909
+ <option value="false" ${!boolVal ? 'selected' : ''}>false</option>
910
+ </select>`;
911
+ } else if (valueType === 'date') {
912
+ return '<input type="text" class="change-rule-value" disabled placeholder="timestamp">';
913
+ } else if (valueType === 'json') {
914
+ return `<input type="text" class="change-rule-value change-rule-json" placeholder='{"key":"value"}' value="${escaped}" onchange="window.updateChangeRule('${nodeId}', '${propName}', ${index}, 'value', this.value)">`;
915
+ } else if (valueType === 'path') {
916
+ return `<input type="text" class="change-rule-value" placeholder="payload.data" value="${escaped}" onchange="window.updateChangeRule('${nodeId}', '${propName}', ${index}, 'value', this.value)">`;
917
+ } else {
918
+ const placeholder = valueType === 'num' ? '0' : valueType === 'env' ? 'ENV_VAR' : '';
919
+ return `<input type="text" class="change-rule-value" placeholder="${placeholder}" value="${escaped}" onchange="window.updateChangeRule('${nodeId}', '${propName}', ${index}, 'value', this.value)">`;
920
+ }
921
+ }
922
+
923
+ export function addChangeRule(nodeId, propName) {
924
+ const nodeData = state.nodes.get(nodeId);
925
+ const rules = nodeData.config[propName] || [];
926
+
927
+ rules.push({
928
+ type: 'set',
929
+ path: 'msg.payload',
930
+ value: '',
931
+ valueType: 'str'
932
+ });
933
+
934
+ nodeData.config[propName] = rules;
935
+
936
+ markNodeModified(nodeId);
937
+ setModified(true);
938
+ renderProperties(nodeData);
939
+ }
940
+
941
+ export function removeChangeRule(nodeId, propName, ruleIndex) {
942
+ const nodeData = state.nodes.get(nodeId);
943
+ const rules = nodeData.config[propName] || [];
944
+
945
+ if (rules.length > 0) {
946
+ rules.splice(ruleIndex, 1);
947
+ nodeData.config[propName] = rules;
948
+
949
+ markNodeModified(nodeId);
950
+ setModified(true);
951
+ renderProperties(nodeData);
952
+ }
953
+ }
954
+
955
+ export function updateChangeRule(nodeId, propName, ruleIndex, field, value) {
956
+ const nodeData = state.nodes.get(nodeId);
957
+ const rules = nodeData.config[propName] || [];
958
+
959
+ if (rules[ruleIndex]) {
960
+ rules[ruleIndex][field] = value;
961
+ nodeData.config[propName] = rules;
962
+
963
+ // Re-render if type changed to update inputs
964
+ if (field === 'type' || field === 'valueType') {
965
+ renderProperties(nodeData);
966
+ }
967
+
968
+ markNodeModified(nodeId);
969
+ setModified(true);
970
+ }
971
+ }
972
+
973
+ // Update property visibility based on current config values
974
+ window.updatePropertyVisibility = function(nodeId) {
975
+ const nodeData = state.nodes.get(nodeId);
976
+ if (!nodeData) return;
977
+
978
+ const nodeType = getNodeType(nodeData.type);
979
+ if (!nodeType || !nodeType.properties) return;
980
+
981
+ // Get all property groups
982
+ const propertyGroups = document.querySelectorAll('.property-group[data-show-if]');
983
+
984
+ propertyGroups.forEach(group => {
985
+ const showIfStr = group.getAttribute('data-show-if');
986
+ if (!showIfStr) return;
987
+
988
+ try {
989
+ const showIf = JSON.parse(showIfStr);
990
+ const shouldShow = shouldShowProperty(showIf, nodeData.config);
991
+
992
+ if (shouldShow) {
993
+ group.style.display = '';
994
+ } else {
995
+ group.style.display = 'none';
996
+ }
997
+ } catch (e) {
998
+ console.error('Error parsing showIf condition:', e);
999
+ }
1000
+ });
1001
+ };
1002
+
1003
+ // ==============================================================================
1004
+ // MQTT Service Management Functions
1005
+ // ==============================================================================
1006
+
1007
+ // Load available MQTT services into a select dropdown
1008
+ window.loadMqttServices = async function(nodeId, propName, currentServiceId) {
1009
+ try {
1010
+ const response = await fetch(`${API_BASE}/services/mqtt`);
1011
+ const data = await response.json();
1012
+
1013
+ if (!data.success) {
1014
+ console.error('Failed to load MQTT services:', data.error);
1015
+ return;
1016
+ }
1017
+
1018
+ const select = document.getElementById(`mqtt-service-${nodeId}`);
1019
+ if (!select) return;
1020
+
1021
+ // Clear existing options except the first placeholder
1022
+ select.innerHTML = '<option value="">-- Select broker --</option>';
1023
+
1024
+ // Add service options
1025
+ data.services.forEach(service => {
1026
+ const option = document.createElement('option');
1027
+ option.value = service.id;
1028
+ option.textContent = `${service.name} (${service.broker}:${service.port})`;
1029
+ if (service.id === currentServiceId) {
1030
+ option.selected = true;
1031
+ }
1032
+ select.appendChild(option);
1033
+ });
1034
+ } catch (error) {
1035
+ console.error('Error loading MQTT services:', error);
1036
+ }
1037
+ };
1038
+
1039
+ // Handle MQTT service selection change
1040
+ window.onMqttServiceSelect = function(nodeId, propName, serviceId) {
1041
+ const nodeData = state.nodes.get(nodeId);
1042
+ if (!nodeData) return;
1043
+
1044
+ nodeData.config[propName] = serviceId;
1045
+ markNodeModified(nodeId);
1046
+ setModified(true);
1047
+ };
1048
+
1049
+ // Open MQTT service editor modal
1050
+ window.openMqttServiceEditor = function(nodeId, propName, isNew = false) {
1051
+ const nodeData = state.nodes.get(nodeId);
1052
+ const currentServiceId = isNew ? null : (nodeData?.config[propName] || null);
1053
+
1054
+ // Create modal HTML
1055
+ const modalHtml = `
1056
+ <div class="mqtt-service-modal" id="mqtt-service-modal">
1057
+ <div class="mqtt-service-modal-content">
1058
+ <div class="mqtt-service-modal-header">
1059
+ <h3>${isNew ? 'Add New MQTT Broker' : 'Edit MQTT Broker'}</h3>
1060
+ <button class="mqtt-service-modal-close" onclick="window.closeMqttServiceModal()">&times;</button>
1061
+ </div>
1062
+ <div class="mqtt-service-modal-body">
1063
+ <input type="hidden" id="mqtt-service-id" value="${currentServiceId || ''}">
1064
+ <div class="property-group">
1065
+ <label class="property-label">Name</label>
1066
+ <input type="text" class="property-input" id="mqtt-service-name" placeholder="My MQTT Broker">
1067
+ </div>
1068
+ <div class="property-group">
1069
+ <label class="property-label">Broker Address</label>
1070
+ <input type="text" class="property-input" id="mqtt-service-broker" placeholder="localhost" value="localhost">
1071
+ </div>
1072
+ <div class="property-group">
1073
+ <label class="property-label">Port</label>
1074
+ <input type="number" class="property-input" id="mqtt-service-port" placeholder="1883" value="1883">
1075
+ </div>
1076
+ <div class="property-group">
1077
+ <label class="property-label">Client ID (optional)</label>
1078
+ <input type="text" class="property-input" id="mqtt-service-clientId" placeholder="Auto-generated if empty">
1079
+ </div>
1080
+ <div class="property-group">
1081
+ <label class="property-label">Username (optional)</label>
1082
+ <input type="text" class="property-input" id="mqtt-service-username">
1083
+ </div>
1084
+ <div class="property-group">
1085
+ <label class="property-label">Password (optional)</label>
1086
+ <input type="password" class="property-input" id="mqtt-service-password">
1087
+ </div>
1088
+ </div>
1089
+ <div class="mqtt-service-modal-footer">
1090
+ <button class="btn btn-secondary" onclick="window.testMqttService()">Test Connection</button>
1091
+ <div class="mqtt-service-modal-actions">
1092
+ ${!isNew ? '<button class="btn btn-danger" onclick="window.deleteMqttService(\'' + nodeId + '\', \'' + propName + '\')">Delete</button>' : ''}
1093
+ <button class="btn btn-secondary" onclick="window.closeMqttServiceModal()">Cancel</button>
1094
+ <button class="btn btn-primary" onclick="window.saveMqttService('${nodeId}', '${propName}', ${isNew})">Save</button>
1095
+ </div>
1096
+ </div>
1097
+ </div>
1098
+ </div>
1099
+ `;
1100
+
1101
+ // Add modal to page
1102
+ document.body.insertAdjacentHTML('beforeend', modalHtml);
1103
+
1104
+ // If editing, load existing service data
1105
+ if (!isNew && currentServiceId) {
1106
+ window.loadMqttServiceData(currentServiceId);
1107
+ }
1108
+ };
1109
+
1110
+ // Load existing service data into the editor
1111
+ window.loadMqttServiceData = async function(serviceId) {
1112
+ try {
1113
+ const response = await fetch(`${API_BASE}/services/mqtt/${serviceId}`);
1114
+ const data = await response.json();
1115
+
1116
+ if (data.success && data.service) {
1117
+ document.getElementById('mqtt-service-name').value = data.service.name || '';
1118
+ document.getElementById('mqtt-service-broker').value = data.service.broker || 'localhost';
1119
+ document.getElementById('mqtt-service-port').value = data.service.port || 1883;
1120
+ document.getElementById('mqtt-service-clientId').value = data.service.clientId || '';
1121
+ document.getElementById('mqtt-service-username').value = data.service.username || '';
1122
+ document.getElementById('mqtt-service-password').value = data.service.password || '';
1123
+ }
1124
+ } catch (error) {
1125
+ console.error('Error loading MQTT service:', error);
1126
+ }
1127
+ };
1128
+
1129
+ // Close the MQTT service modal
1130
+ window.closeMqttServiceModal = function() {
1131
+ const modal = document.getElementById('mqtt-service-modal');
1132
+ if (modal) {
1133
+ modal.remove();
1134
+ }
1135
+ };
1136
+
1137
+ // Save MQTT service (create or update)
1138
+ window.saveMqttService = async function(nodeId, propName, isNew) {
1139
+ const serviceId = document.getElementById('mqtt-service-id').value;
1140
+ const serviceData = {
1141
+ name: document.getElementById('mqtt-service-name').value,
1142
+ broker: document.getElementById('mqtt-service-broker').value,
1143
+ port: parseInt(document.getElementById('mqtt-service-port').value) || 1883,
1144
+ clientId: document.getElementById('mqtt-service-clientId').value,
1145
+ username: document.getElementById('mqtt-service-username').value,
1146
+ password: document.getElementById('mqtt-service-password').value
1147
+ };
1148
+
1149
+ // Validate
1150
+ if (!serviceData.name) {
1151
+ alert('Please enter a name for the broker');
1152
+ return;
1153
+ }
1154
+ if (!serviceData.broker) {
1155
+ alert('Please enter a broker address');
1156
+ return;
1157
+ }
1158
+
1159
+ try {
1160
+ let response;
1161
+ if (isNew || !serviceId) {
1162
+ // Create new service
1163
+ response = await fetch(`${API_BASE}/services/mqtt`, {
1164
+ method: 'POST',
1165
+ headers: { 'Content-Type': 'application/json' },
1166
+ body: JSON.stringify(serviceData)
1167
+ });
1168
+ } else {
1169
+ // Update existing service
1170
+ response = await fetch(`${API_BASE}/services/mqtt/${serviceId}`, {
1171
+ method: 'PUT',
1172
+ headers: { 'Content-Type': 'application/json' },
1173
+ body: JSON.stringify(serviceData)
1174
+ });
1175
+ }
1176
+
1177
+ const data = await response.json();
1178
+
1179
+ if (data.success) {
1180
+ // Update node config with new/updated service ID
1181
+ const nodeData = state.nodes.get(nodeId);
1182
+ if (nodeData) {
1183
+ nodeData.config[propName] = data.service.id;
1184
+ markNodeModified(nodeId);
1185
+ setModified(true);
1186
+ }
1187
+
1188
+ // Reload services dropdown
1189
+ await window.loadMqttServices(nodeId, propName, data.service.id);
1190
+
1191
+ // Close modal
1192
+ window.closeMqttServiceModal();
1193
+ } else {
1194
+ alert('Failed to save: ' + (data.error || 'Unknown error'));
1195
+ }
1196
+ } catch (error) {
1197
+ console.error('Error saving MQTT service:', error);
1198
+ alert('Error saving MQTT service');
1199
+ }
1200
+ };
1201
+
1202
+ // Test MQTT service connection
1203
+ window.testMqttService = async function() {
1204
+ const serviceId = document.getElementById('mqtt-service-id').value;
1205
+
1206
+ if (!serviceId) {
1207
+ // For new services, we need to save first
1208
+ alert('Please save the broker configuration first, then test the connection.');
1209
+ return;
1210
+ }
1211
+
1212
+ try {
1213
+ const response = await fetch(`${API_BASE}/services/mqtt/${serviceId}/test`, {
1214
+ method: 'POST'
1215
+ });
1216
+ const data = await response.json();
1217
+
1218
+ if (data.success) {
1219
+ if (data.connected) {
1220
+ alert('✓ Connection successful!');
1221
+ } else {
1222
+ alert('✗ Connection failed. Please check your settings.');
1223
+ }
1224
+ } else {
1225
+ alert('Test failed: ' + (data.error || 'Unknown error'));
1226
+ }
1227
+ } catch (error) {
1228
+ console.error('Error testing MQTT service:', error);
1229
+ alert('Error testing connection');
1230
+ }
1231
+ };
1232
+
1233
+ // Delete MQTT service
1234
+ window.deleteMqttService = async function(nodeId, propName) {
1235
+ const serviceId = document.getElementById('mqtt-service-id').value;
1236
+
1237
+ if (!serviceId) return;
1238
+
1239
+ if (!confirm('Are you sure you want to delete this broker configuration?')) {
1240
+ return;
1241
+ }
1242
+
1243
+ try {
1244
+ const response = await fetch(`${API_BASE}/services/mqtt/${serviceId}`, {
1245
+ method: 'DELETE'
1246
+ });
1247
+ const data = await response.json();
1248
+
1249
+ if (data.success) {
1250
+ // Clear node config
1251
+ const nodeData = state.nodes.get(nodeId);
1252
+ if (nodeData) {
1253
+ nodeData.config[propName] = '';
1254
+ markNodeModified(nodeId);
1255
+ setModified(true);
1256
+ }
1257
+
1258
+ // Reload services dropdown
1259
+ await window.loadMqttServices(nodeId, propName, '');
1260
+
1261
+ // Close modal
1262
+ window.closeMqttServiceModal();
1263
+ } else {
1264
+ alert('Failed to delete: ' + (data.error || 'Service may still be in use'));
1265
+ }
1266
+ } catch (error) {
1267
+ console.error('Error deleting MQTT service:', error);
1268
+ alert('Error deleting MQTT service');
1269
+ }
1270
+ };