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,344 @@
1
+ // Multi-workflow tab management
2
+ import { API_BASE } from './config.js';
3
+ import { state, setModified, setWorkflowStopped, clearChangeTracking, saveActiveWorkflowToCache, restoreWorkflowFromCache } from './state.js';
4
+ import { renderNode } from './nodes.js';
5
+ import { updateConnections } from './connections.js';
6
+ import { showToast } from './ui-utils.js';
7
+ import { deselectAllNodes } from './selection.js';
8
+ import { loadWorkflowData, enrichNodeWithTypeInfo } from './workflow.js';
9
+
10
+ /**
11
+ * Initialize workflow tabs from the server.
12
+ */
13
+ export async function initWorkflowTabs() {
14
+ try {
15
+ const response = await fetch(`${API_BASE}/workflows`);
16
+ const workflows = await response.json();
17
+
18
+ state.workflows.clear();
19
+ let activeId = null;
20
+
21
+ workflows.forEach(wf => {
22
+ state.workflows.set(wf.id, {
23
+ name: wf.name,
24
+ enabled: wf.enabled,
25
+ nodeCount: wf.nodeCount
26
+ });
27
+ if (wf.active) activeId = wf.id;
28
+ });
29
+
30
+ // Restore the transient "stopped" indicator across page reloads:
31
+ // if every enabled workflow's deployed engine is not running, the
32
+ // flows were stopped via the deploy menu.
33
+ const enabledWfs = workflows.filter(wf => wf.enabled);
34
+ setWorkflowStopped(enabledWfs.length > 0 &&
35
+ enabledWfs.every(wf => wf.running === false));
36
+
37
+ if (!activeId && state.workflows.size > 0) {
38
+ activeId = state.workflows.keys().next().value;
39
+ }
40
+
41
+ state.activeWorkflowId = activeId;
42
+ renderTabs();
43
+ } catch (error) {
44
+ console.error('Failed to load workflows:', error);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Render the workflow tab bar.
50
+ */
51
+ export function renderTabs() {
52
+ const container = document.getElementById('workflow-tabs');
53
+ if (!container) return;
54
+
55
+ // Remove existing tabs (but keep the + button)
56
+ const addBtn = document.getElementById('add-workflow-btn');
57
+ container.querySelectorAll('.workflow-tab').forEach(el => el.remove());
58
+
59
+ state.workflows.forEach((meta, wid) => {
60
+ const tab = document.createElement('button');
61
+ tab.className = 'workflow-tab';
62
+ if (wid === state.activeWorkflowId) tab.classList.add('active');
63
+ if (!meta.enabled) tab.classList.add('disabled');
64
+ tab.dataset.workflowId = wid;
65
+
66
+ const nameSpan = document.createElement('span');
67
+ nameSpan.className = 'tab-name';
68
+ nameSpan.textContent = meta.name;
69
+ tab.appendChild(nameSpan);
70
+
71
+ const closeBtn = document.createElement('span');
72
+ closeBtn.className = 'tab-close';
73
+ closeBtn.textContent = '×';
74
+ closeBtn.addEventListener('click', (e) => {
75
+ e.stopPropagation();
76
+ deleteWorkflow(wid);
77
+ });
78
+ tab.appendChild(closeBtn);
79
+
80
+ // Single click: switch to workflow (if not active) or open properties (if active)
81
+ tab.addEventListener('click', (e) => {
82
+ if (e.target === closeBtn) return;
83
+ if (wid === state.activeWorkflowId) {
84
+ // Already active - open properties panel for workflow editing
85
+ showWorkflowProperties(wid);
86
+ } else {
87
+ switchWorkflow(wid);
88
+ }
89
+ });
90
+
91
+ // Double click: start inline rename
92
+ tab.addEventListener('dblclick', (e) => {
93
+ e.stopPropagation();
94
+ startInlineRename(wid, nameSpan);
95
+ });
96
+
97
+ container.insertBefore(tab, addBtn);
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Switch to a different workflow tab.
103
+ */
104
+ export async function switchWorkflow(workflowId) {
105
+ if (workflowId === state.activeWorkflowId) return;
106
+ if (!state.workflows.has(workflowId)) return;
107
+
108
+ // Save current workflow state to cache
109
+ saveActiveWorkflowToCache();
110
+
111
+ // Clear current canvas
112
+ deselectAllNodes();
113
+ document.getElementById('nodes-container').innerHTML = '';
114
+ document.getElementById('connections').innerHTML = '';
115
+
116
+ // Set new active workflow
117
+ state.activeWorkflowId = workflowId;
118
+
119
+ // Tell the server about the active workflow
120
+ fetch(`${API_BASE}/workflows/active`, {
121
+ method: 'PUT',
122
+ headers: { 'Content-Type': 'application/json' },
123
+ body: JSON.stringify({ workflowId })
124
+ }).catch(err => console.error('Failed to set active workflow:', err));
125
+
126
+ // Try to restore from cache first
127
+ if (restoreWorkflowFromCache(workflowId)) {
128
+ // Re-render from cached state
129
+ state.nodes.forEach(nodeData => renderNode(nodeData));
130
+ updateConnections();
131
+ setModified(state.isModified);
132
+ } else {
133
+ // Load from server
134
+ await loadWorkflowData(workflowId);
135
+ }
136
+
137
+ // Close properties panel when switching
138
+ const propertiesPanel = document.getElementById('properties-panel-container');
139
+ if (propertiesPanel) propertiesPanel.classList.add('hidden');
140
+
141
+ // Clear history for the new tab
142
+ import('./history.js').then(({ clearHistory }) => {
143
+ clearHistory();
144
+ });
145
+
146
+ renderTabs();
147
+ }
148
+
149
+ /**
150
+ * Create a new workflow.
151
+ */
152
+ export async function createNewWorkflow(name) {
153
+ try {
154
+ const response = await fetch(`${API_BASE}/workflows`, {
155
+ method: 'POST',
156
+ headers: { 'Content-Type': 'application/json' },
157
+ body: JSON.stringify({ name: name || 'New Workflow' })
158
+ });
159
+
160
+ if (!response.ok) throw new Error('Failed to create workflow');
161
+
162
+ const wf = await response.json();
163
+ state.workflows.set(wf.id, {
164
+ name: wf.name,
165
+ enabled: wf.enabled,
166
+ nodeCount: 0
167
+ });
168
+
169
+ // Switch to the new workflow
170
+ await switchWorkflow(wf.id);
171
+ showToast(`Created workflow: ${wf.name}`);
172
+ } catch (error) {
173
+ console.error('Failed to create workflow:', error);
174
+ showToast('Failed to create workflow');
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Delete a workflow.
180
+ */
181
+ async function deleteWorkflow(workflowId) {
182
+ if (state.workflows.size <= 1) {
183
+ showToast('Cannot delete the last workflow');
184
+ return;
185
+ }
186
+
187
+ const name = state.workflows.get(workflowId)?.name || 'this workflow';
188
+ if (!confirm(`Delete workflow "${name}"?`)) return;
189
+
190
+ try {
191
+ const response = await fetch(`${API_BASE}/workflows/${workflowId}`, {
192
+ method: 'DELETE'
193
+ });
194
+
195
+ if (!response.ok) throw new Error('Failed to delete workflow');
196
+
197
+ const result = await response.json();
198
+
199
+ // Remove from local state
200
+ state.workflows.delete(workflowId);
201
+ state.workflowCache.delete(workflowId);
202
+
203
+ // If we deleted the active workflow, switch to what server says
204
+ if (workflowId === state.activeWorkflowId) {
205
+ state.activeWorkflowId = result.activeWorkflow;
206
+ // Clear canvas and load the new active workflow
207
+ document.getElementById('nodes-container').innerHTML = '';
208
+ document.getElementById('connections').innerHTML = '';
209
+ await loadWorkflowData(state.activeWorkflowId);
210
+ }
211
+
212
+ renderTabs();
213
+ showToast(`Deleted workflow: ${name}`);
214
+ } catch (error) {
215
+ console.error('Failed to delete workflow:', error);
216
+ showToast('Failed to delete workflow');
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Start inline rename on a tab.
222
+ */
223
+ function startInlineRename(workflowId, nameSpan) {
224
+ nameSpan.contentEditable = 'true';
225
+ nameSpan.focus();
226
+
227
+ // Select all text
228
+ const range = document.createRange();
229
+ range.selectNodeContents(nameSpan);
230
+ const sel = window.getSelection();
231
+ sel.removeAllRanges();
232
+ sel.addRange(range);
233
+
234
+ const finish = async () => {
235
+ nameSpan.contentEditable = 'false';
236
+ const newName = nameSpan.textContent.trim();
237
+ if (!newName) {
238
+ // Revert to old name
239
+ nameSpan.textContent = state.workflows.get(workflowId)?.name || 'Untitled';
240
+ return;
241
+ }
242
+
243
+ try {
244
+ const response = await fetch(`${API_BASE}/workflows/${workflowId}`, {
245
+ method: 'PUT',
246
+ headers: { 'Content-Type': 'application/json' },
247
+ body: JSON.stringify({ name: newName })
248
+ });
249
+
250
+ if (response.ok) {
251
+ const result = await response.json();
252
+ const meta = state.workflows.get(workflowId);
253
+ if (meta) meta.name = result.name;
254
+ nameSpan.textContent = result.name;
255
+ }
256
+ } catch (error) {
257
+ console.error('Failed to rename workflow:', error);
258
+ }
259
+ };
260
+
261
+ nameSpan.addEventListener('blur', finish, { once: true });
262
+ nameSpan.addEventListener('keydown', (e) => {
263
+ if (e.key === 'Enter') {
264
+ e.preventDefault();
265
+ nameSpan.blur();
266
+ }
267
+ if (e.key === 'Escape') {
268
+ nameSpan.textContent = state.workflows.get(workflowId)?.name || 'Untitled';
269
+ nameSpan.contentEditable = 'false';
270
+ }
271
+ });
272
+ }
273
+
274
+ /**
275
+ * Show workflow properties in the properties panel.
276
+ */
277
+ function showWorkflowProperties(workflowId) {
278
+ const meta = state.workflows.get(workflowId);
279
+ if (!meta) return;
280
+
281
+ const panel = document.getElementById('properties-panel');
282
+ const container = document.getElementById('properties-panel-container');
283
+
284
+ // Deselect any selected node
285
+ deselectAllNodes();
286
+
287
+ panel.innerHTML = `
288
+ <div class="workflow-properties">
289
+ <div class="property-group">
290
+ <label class="property-label">Workflow Name</label>
291
+ <input type="text" class="property-input" id="wf-prop-name" value="${meta.name}" />
292
+ </div>
293
+ <div class="property-group">
294
+ <label class="property-label">
295
+ <input type="checkbox" id="wf-prop-enabled" ${meta.enabled ? 'checked' : ''} />
296
+ Enabled
297
+ </label>
298
+ <span class="property-hint">Disabled workflows won't run when deployed</span>
299
+ </div>
300
+ </div>
301
+ `;
302
+
303
+ container.classList.remove('hidden');
304
+
305
+ // Wire up change handlers
306
+ const nameInput = document.getElementById('wf-prop-name');
307
+ nameInput.addEventListener('change', async () => {
308
+ const newName = nameInput.value.trim();
309
+ if (!newName) return;
310
+ try {
311
+ const response = await fetch(`${API_BASE}/workflows/${workflowId}`, {
312
+ method: 'PUT',
313
+ headers: { 'Content-Type': 'application/json' },
314
+ body: JSON.stringify({ name: newName })
315
+ });
316
+ if (response.ok) {
317
+ const result = await response.json();
318
+ meta.name = result.name;
319
+ nameInput.value = result.name;
320
+ renderTabs();
321
+ }
322
+ } catch (error) {
323
+ console.error('Failed to update workflow name:', error);
324
+ }
325
+ });
326
+
327
+ const enabledCheckbox = document.getElementById('wf-prop-enabled');
328
+ enabledCheckbox.addEventListener('change', async () => {
329
+ try {
330
+ const response = await fetch(`${API_BASE}/workflows/${workflowId}`, {
331
+ method: 'PUT',
332
+ headers: { 'Content-Type': 'application/json' },
333
+ body: JSON.stringify({ enabled: enabledCheckbox.checked })
334
+ });
335
+ if (response.ok) {
336
+ const result = await response.json();
337
+ meta.enabled = result.enabled;
338
+ renderTabs();
339
+ }
340
+ } catch (error) {
341
+ console.error('Failed to update workflow enabled state:', error);
342
+ }
343
+ });
344
+ }