@gmag11/nodered-mcp-server 1.0.1

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 (89) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +162 -0
  3. package/index.js +133 -0
  4. package/package.json +58 -0
  5. package/resources/skills/nodered-flow-builder/SKILL.md +659 -0
  6. package/resources/skills/nodered-flow-layout/SKILL.md +395 -0
  7. package/resources/skills/nodered-flowfuse-dashboard/SKILL.md +941 -0
  8. package/resources/skills/nodered-fundamentals/SKILL.md +323 -0
  9. package/resources/skills/nodered-jsonata/SKILL.md +1039 -0
  10. package/resources/skills/nodered-mustache/SKILL.md +588 -0
  11. package/resources/skills/nodered-node-reference/SKILL.md +1020 -0
  12. package/resources/skills/nodered-node-reference/examples/common.json +113 -0
  13. package/resources/skills/nodered-node-reference/examples/network.json +107 -0
  14. package/resources/skills/nodered-node-reference/examples/parser.json +147 -0
  15. package/resources/skills/nodered-node-reference/examples/sequence.json +141 -0
  16. package/resources/skills/nodered-node-reference/examples/storage.json +104 -0
  17. package/resources/skills/nodered-patterns/SKILL.md +414 -0
  18. package/resources/skills/nodered-patterns/examples/error-handler.json +72 -0
  19. package/resources/skills/nodered-patterns/examples/http-endpoint.json +42 -0
  20. package/resources/skills/nodered-patterns/examples/mqtt-subscriber.json +47 -0
  21. package/resources/skills/nodered-patterns/examples/timer-flow.json +50 -0
  22. package/resources/skills/nodered-subflows/SKILL.md +261 -0
  23. package/resources/skills/nodered-uibuilder/SKILL.md +500 -0
  24. package/src/auth/api-key-verifier.js +36 -0
  25. package/src/auth/composite-verifier.js +59 -0
  26. package/src/auth/config.js +106 -0
  27. package/src/auth/oauth-clients-store.js +107 -0
  28. package/src/auth/oauth-provider.js +149 -0
  29. package/src/auth/oauth-token-store.js +312 -0
  30. package/src/nodered/auth.js +158 -0
  31. package/src/nodered/client.js +199 -0
  32. package/src/nodered/comms-client.js +500 -0
  33. package/src/renderer/colors.js +161 -0
  34. package/src/renderer/geometry.js +115 -0
  35. package/src/renderer/html-builder.js +571 -0
  36. package/src/renderer/index.js +51 -0
  37. package/src/renderer/ir-builder.js +161 -0
  38. package/src/renderer/layout.js +126 -0
  39. package/src/renderer/mermaid-builder.js +109 -0
  40. package/src/renderer/svg-builder.js +228 -0
  41. package/src/schemas/responses.js +283 -0
  42. package/src/server.js +844 -0
  43. package/src/skills/loader.js +84 -0
  44. package/src/staging-store.js +258 -0
  45. package/src/tools/add-nodes-to-group.js +216 -0
  46. package/src/tools/connect-nodes.js +115 -0
  47. package/src/tools/constants.js +45 -0
  48. package/src/tools/create-flow.js +87 -0
  49. package/src/tools/create-node.js +126 -0
  50. package/src/tools/create-subflow-instance.js +123 -0
  51. package/src/tools/create-subflow.js +101 -0
  52. package/src/tools/delete-context.js +60 -0
  53. package/src/tools/delete-flow.js +81 -0
  54. package/src/tools/delete-group.js +116 -0
  55. package/src/tools/delete-node.js +73 -0
  56. package/src/tools/delete-subflow.js +103 -0
  57. package/src/tools/deploy.js +94 -0
  58. package/src/tools/disconnect-nodes.js +158 -0
  59. package/src/tools/export-flow.js +161 -0
  60. package/src/tools/export-subflow.js +78 -0
  61. package/src/tools/flow-utils.js +376 -0
  62. package/src/tools/get-config-nodes.js +86 -0
  63. package/src/tools/get-context.js +76 -0
  64. package/src/tools/get-flow-diagram.js +99 -0
  65. package/src/tools/get-flow-nodes.js +116 -0
  66. package/src/tools/get-flows.js +74 -0
  67. package/src/tools/get-node-detail.js +77 -0
  68. package/src/tools/get-node-type-detail.js +92 -0
  69. package/src/tools/get-palette-nodes.js +63 -0
  70. package/src/tools/get-staging-status.js +34 -0
  71. package/src/tools/get-subflow-detail.js +110 -0
  72. package/src/tools/get-subflows.js +105 -0
  73. package/src/tools/import-flow.js +310 -0
  74. package/src/tools/inject-message.js +117 -0
  75. package/src/tools/install-node.js +31 -0
  76. package/src/tools/read-debug-messages.js +155 -0
  77. package/src/tools/refresh-staging.js +62 -0
  78. package/src/tools/remove-nodes-from-group.js +162 -0
  79. package/src/tools/render-staging.js +69 -0
  80. package/src/tools/response-utils.js +42 -0
  81. package/src/tools/search-nodes.js +134 -0
  82. package/src/tools/uninstall-node.js +31 -0
  83. package/src/tools/update-flow.js +95 -0
  84. package/src/tools/update-group.js +77 -0
  85. package/src/tools/update-node.js +132 -0
  86. package/src/tools/update-subflow.js +84 -0
  87. package/src/transport/http.js +252 -0
  88. package/src/transport/stdio.js +16 -0
  89. package/src/transport/ws-server.js +223 -0
@@ -0,0 +1,571 @@
1
+ /**
2
+ * HTML Builder
3
+ *
4
+ * Generates a self-contained HTML document with D3.js rendering,
5
+ * WebSocket live refresh, zoom/pan, hover tooltips, dirty highlighting,
6
+ * and tabbed flow navigation matching the Node-RED editor interface.
7
+ *
8
+ * @module renderer/html-builder
9
+ */
10
+
11
+ /**
12
+ * Default node dimensions if not specified in raw flow data.
13
+ */
14
+ const DEFAULT_W = 100;
15
+ const DEFAULT_H = 30;
16
+
17
+ /**
18
+ * Color map for embedding in the HTML (subset of most common types).
19
+ */
20
+ const COLOR_MAP = {
21
+ 'inject': '#a6bbcf', 'debug': '#87a980', 'function': '#fdd0a2',
22
+ 'switch': '#d8bfd8', 'change': '#e2d6b8', 'range': '#d8bfd8',
23
+ 'template': '#d8bfd8', 'delay': '#fdd0a2', 'trigger': '#fdd0a2',
24
+ 'exec': '#fdd0a2', 'complete': '#c0c0c0', 'catch': '#c0c0c0',
25
+ 'status': '#c0c0c0', 'comment': '#ffffff', 'unknown': '#c0c0c0',
26
+ 'link in': '#c0c0c0', 'link out': '#c0c0c0', 'link call': '#c0c0c0',
27
+ 'mqtt in': '#d8bfd8', 'mqtt out': '#d8bfd8',
28
+ 'http in': '#d8bfd8', 'http response': '#d8bfd8', 'http request': '#e2d6b8',
29
+ 'split': '#d8bfd8', 'join': '#d8bfd8', 'batch': '#d8bfd8', 'sort': '#d8bfd8',
30
+ 'csv': '#d8bfd8', 'html': '#d8bfd8', 'json': '#d8bfd8', 'xml': '#d8bfd8', 'yaml': '#d8bfd8',
31
+ 'file in': '#87a980', 'file out': '#87a980', 'file': '#87a980', 'watch': '#87a980',
32
+ 'rbe': '#fdd0a2', 'ui_button': '#d8bfd8', 'ui_text': '#d8bfd8',
33
+ 'ui_gauge': '#d8bfd8', 'ui_chart': '#d8bfd8',
34
+ };
35
+
36
+ /**
37
+ * Build a self-contained HTML document string that renders the staging workspace
38
+ * with a tab bar for multiple flows.
39
+ *
40
+ * @param {object[]} flows - Raw Node-RED flows array (from StagingStore)
41
+ * @param {object} options - Rendering options
42
+ * @param {boolean} [options.highlightDirty=true] - Highlight un-deployed nodes
43
+ * @param {Set<string>} [options.dirtyNodeIds] - Set of dirty node IDs
44
+ * @returns {string} Complete HTML document
45
+ */
46
+ export function buildHTML(flows, options = {}) {
47
+ const {
48
+ highlightDirty = true,
49
+ dirtyNodeIds = new Set(),
50
+ } = options;
51
+
52
+ const dirtyArr = [...dirtyNodeIds];
53
+ // Escape newlines and other control characters for safe embedding in HTML <script>
54
+ // Also escape </ sequences to prevent premature script tag closure (e.g., if
55
+ // function node code contains </script> or </body> etc.)
56
+ const flowsJSON = JSON.stringify(flows)
57
+ .replace(/\n/g, '\\n')
58
+ .replace(/\r/g, '\\r')
59
+ .replace(/\t/g, '\\t')
60
+ .replace(/<\//g, '<\\/');
61
+ const colorMapJSON = JSON.stringify(COLOR_MAP).replace(/<\//g, '<\\/');
62
+ const dirtyNodeIdsJSON = JSON.stringify(dirtyArr);
63
+ const hd = highlightDirty;
64
+
65
+ // The HTML template is built as a regular string concatenation to avoid
66
+ // template-literal escaping issues with the embedded JavaScript code.
67
+ const html = [
68
+ '<!DOCTYPE html>',
69
+ '<html lang="en">',
70
+ '<head>',
71
+ '<meta charset="UTF-8">',
72
+ '<meta name="viewport" content="width=device-width, initial-scale=1.0">',
73
+ '<title>Node-RED Staging Preview</title>',
74
+ '<script src="https://d3js.org/d3.v7.min.js"></script>',
75
+ '<style>',
76
+ ' * { margin: 0; padding: 0; box-sizing: border-box; }',
77
+ ' body { overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }',
78
+ ' #tab-bar {',
79
+ ' position: fixed; top: 0; left: 0; right: 0; height: 34px;',
80
+ ' background: #e0e0e0; z-index: 1001;',
81
+ ' display: flex; overflow-x: auto; overflow-y: hidden;',
82
+ ' white-space: nowrap; padding: 0; margin: 0;',
83
+ ' border-bottom: 1px solid #bbb;',
84
+ ' box-shadow: 0 1px 3px rgba(0,0,0,0.08);',
85
+ ' }',
86
+ ' #tab-bar::-webkit-scrollbar { height: 4px; }',
87
+ ' #tab-bar::-webkit-scrollbar-thumb { background: #aaa; border-radius: 2px; }',
88
+ ' #tab-bar-actions { margin-left: auto; flex-shrink: 0; display: flex; align-items: center; padding: 0 8px; border-left: 1px solid #ccc; background: #e0e0e0; }',
89
+ ' #btn-refresh { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; font-size: 11px; cursor: pointer; background: #fff; border: 1px solid #bbb; border-radius: 3px; color: #444; user-select: none; transition: background 0.15s; }',
90
+ ' #btn-refresh:hover { background: #f0f0f0; }',
91
+ ' #btn-refresh:active { background: #e4e4e4; }',
92
+ ' #btn-refresh.loading { opacity: 0.6; pointer-events: none; }',
93
+ ' #btn-refresh .icon { display: inline-block; transition: transform 0.5s; }',
94
+ ' #btn-refresh.loading .icon { animation: spin 0.7s linear infinite; }',
95
+ ' @keyframes spin { to { transform: rotate(360deg); } }',
96
+ ' .nr-tab {',
97
+ ' display: inline-flex; align-items: center;',
98
+ ' padding: 0 18px; cursor: pointer; font-size: 12px;',
99
+ ' color: #555; border-right: 1px solid #ccc;',
100
+ ' user-select: none; flex-shrink: 0; height: 100%;',
101
+ ' transition: background 0.15s;',
102
+ ' }',
103
+ ' .nr-tab.active {',
104
+ ' background: #fff; color: #333; font-weight: 600;',
105
+ ' box-shadow: inset 0 -2px 0 #c72;',
106
+ ' }',
107
+ ' .nr-tab:hover:not(.active) { background: #d6d6d6; }',
108
+ ' .nr-tab.dirty::after {',
109
+ " content: '\u25cf'; font-size: 8px; color: #ff8c00;",
110
+ ' margin-left: 6px; line-height: 1;',
111
+ ' }',
112
+ ' #canvas-wrap { position: fixed; top: 0; left: 0; right: 0; bottom: 0; }',
113
+ ' #canvas-wrap.with-tabs { top: 34px; }',
114
+ ' #canvas { width: 100%; height: 100%; cursor: grab; }',
115
+ ' #canvas:active { cursor: grabbing; }',
116
+ ' #banner {',
117
+ ' display: none; position: fixed; top: 0; left: 0; right: 0;',
118
+ ' background: #fff3cd; color: #856404; text-align: center;',
119
+ ' padding: 5px 12px; font-size: 12px; z-index: 2000;',
120
+ ' border-bottom: 1px solid #ffc107;',
121
+ ' }',
122
+ ' #banner.visible { display: block; }',
123
+ ' .nr-node { cursor: pointer; transition: filter 0.2s; }',
124
+ ' .nr-node:hover { filter: brightness(1.1); }',
125
+ ' .nr-node-dirty .nr-node-body { stroke: #ff8c00; stroke-width: 2.5; }',
126
+ ' .nr-node-disabled .nr-node-body { stroke-dasharray: 5 5; opacity: 0.5; }',
127
+ ' .nr-link { fill: none; stroke: #999; stroke-width: 1.5; }',
128
+ ' .nr-link-disabled { stroke: #ccc; stroke-dasharray: 4 2; }',
129
+ ' .nr-group-rect { fill: none; stroke: #999; stroke-dasharray: 5 3; stroke-opacity: 0.5; }',
130
+ ' .nr-tooltip {',
131
+ ' position: fixed; pointer-events: none; z-index: 999;',
132
+ ' background: rgba(0,0,0,0.85); color: #fff; padding: 8px 12px;',
133
+ ' border-radius: 6px; font-size: 12px; line-height: 1.5;',
134
+ ' max-width: 260px; white-space: normal;',
135
+ ' }',
136
+ ' .nr-tooltip .tt-name { font-weight: bold; font-size: 13px; }',
137
+ ' .nr-tooltip .tt-dirty { color: #ff8c00; }',
138
+ ' .nr-legend {',
139
+ ' position: fixed; bottom: 12px; left: 12px; z-index: 500;',
140
+ ' background: rgba(255,255,255,0.9); border: 1px solid #ccc;',
141
+ ' border-radius: 6px; padding: 6px 12px; font-size: 11px; color: #666;',
142
+ ' }',
143
+ ' .nr-empty {',
144
+ ' position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);',
145
+ ' color: #aaa; font-size: 14px; text-align: center; pointer-events: none;',
146
+ ' }',
147
+ '</style>',
148
+ '</head>',
149
+ '<body>',
150
+ '<div id="banner">\u26a0\ufe0f Disconnected \u2014 retrying\u2026</div>',
151
+ '<div id="tab-bar"><div id="tab-bar-actions"><button id="btn-refresh"><span class="icon">&#x21bb;</span> Refresh</button></div></div>',
152
+ '<div id="canvas-wrap">',
153
+ ' <div id="canvas"></div>',
154
+ '</div>',
155
+ '<div class="nr-legend" id="legend" style="display:none">\ud83d\udfe0 Orange border = Un-deployed changes</div>',
156
+ '<div class="nr-tooltip" id="tooltip" style="display:none"></div>',
157
+ '',
158
+ '<script>',
159
+ 'var ALL_FLOWS = ' + flowsJSON + ';',
160
+ 'var DIRTY_NODE_IDS = new Set(' + dirtyNodeIdsJSON + ');',
161
+ 'var HIGHLIGHT_DIRTY = ' + hd + ';',
162
+ 'var COLOR_MAP = ' + colorMapJSON + ';',
163
+ 'var DEFAULT_COLOR = "#cccccc";',
164
+ 'var DEFAULT_W = ' + DEFAULT_W + ';',
165
+ 'var DEFAULT_H = ' + DEFAULT_H + ';',
166
+ '',
167
+ '(function() {',
168
+ ' var banner = document.getElementById("banner");',
169
+ ' var tooltip = document.getElementById("tooltip");',
170
+ ' var legend = document.getElementById("legend");',
171
+ ' var tabBar = document.getElementById("tab-bar");',
172
+ ' var btnRefresh = document.getElementById("btn-refresh");',
173
+ ' var canvasWrap = document.getElementById("canvas-wrap");',
174
+ ' var canvasEl = document.getElementById("canvas");',
175
+ '',
176
+ ' function extractTabs(flows) {',
177
+ ' return flows.filter(function(n) { return n.type === "tab"; })',
178
+ ' .map(function(t) { return { id: t.id, name: t.label || t.name || t.id, disabled: t.d === true }; });',
179
+ ' }',
180
+ '',
181
+ ' function buildTabData(flows, tabId, dirtySet) {',
182
+ ' var tabNodes = flows.filter(function(n) {',
183
+ ' return n.z === tabId && n.type !== "tab" && n.type !== "group" && n.type !== "junction"',
184
+ ' && Array.isArray(n.wires);',
185
+ ' });',
186
+ ' var tabGroups = flows.filter(function(n) {',
187
+ ' return n.z === tabId && n.type === "group";',
188
+ ' });',
189
+ ' var nodeIdSet = new Set(tabNodes.map(function(n) { return n.id; }));',
190
+ ' var nodes = tabNodes.map(function(n) {',
191
+ ' var outs = n.outputs != null ? n.outputs : (Array.isArray(n.wires) ? n.wires.length : 0);',
192
+ ' var ins = n.inputs != null ? n.inputs : 0;',
193
+ ' var portH = Math.max(outs, ins, 1) * 13 + 8;',
194
+ ' var nodeH = Math.max(n.h || DEFAULT_H, portH);',
195
+ ' return {',
196
+ ' id: n.id, type: n.type, name: n.name || n.type,',
197
+ ' x: n.x || 0, y: n.y || 0, w: n.w || DEFAULT_W, h: nodeH,',
198
+ ' inputs: ins,',
199
+ ' outputs: outs,',
200
+ ' d: n.d === true,',
201
+ ' dirty: HIGHLIGHT_DIRTY ? dirtySet.has(n.id) : false,',
202
+ ' wires: n.wires || []',
203
+ ' };',
204
+ ' });',
205
+ ' var groups = tabGroups.map(function(g) {',
206
+ ' return {',
207
+ ' id: g.id, name: g.name || "Group",',
208
+ ' x: g.x || 0, y: g.y || 0, w: g.w || 40, h: g.h || 40,',
209
+ ' style: g.style || {},',
210
+ ' nodes: (g.nodes || []).filter(function(mid) { return nodeIdSet.has(mid); })',
211
+ ' };',
212
+ ' });',
213
+ ' var links = [];',
214
+ ' for (var i = 0; i < nodes.length; i++) {',
215
+ ' var node = nodes[i];',
216
+ ' if (!node.wires || node.wires.length === 0) continue;',
217
+ ' node.wires.forEach(function(targets, portIndex) {',
218
+ ' if (!Array.isArray(targets)) return;',
219
+ ' for (var j = 0; j < targets.length; j++) {',
220
+ ' var tn = nodes.find(function(n) { return n.id === targets[j]; });',
221
+ ' if (tn) links.push({ source: node, sourcePort: portIndex, target: tn });',
222
+ ' }',
223
+ ' });',
224
+ ' }',
225
+ ' var bb = computeBBox(nodes, groups);',
226
+ ' return { nodes: nodes, groups: groups, links: links, bb: bb };',
227
+ ' }',
228
+ '',
229
+ ' function computeBBox(nodes, groups) {',
230
+ ' if (nodes.length === 0 && groups.length === 0) {',
231
+ ' return { minX: 0, minY: 0, maxX: 400, maxY: 200, width: 400, height: 200 };',
232
+ ' }',
233
+ ' var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;',
234
+ ' for (var i = 0; i < nodes.length; i++) {',
235
+ ' var n = nodes[i];',
236
+ ' if (n.x - n.w/2 < minX) minX = n.x - n.w/2;',
237
+ ' if (n.x + n.w/2 > maxX) maxX = n.x + n.w/2;',
238
+ ' if (n.y - n.h/2 < minY) minY = n.y - n.h/2;',
239
+ ' if (n.y + n.h/2 > maxY) maxY = n.y + n.h/2;',
240
+ ' }',
241
+ ' for (var j = 0; j < groups.length; j++) {',
242
+ ' var g = groups[j];',
243
+ ' if (g.x < minX) minX = g.x;',
244
+ ' if (g.y < minY) minY = g.y;',
245
+ ' if (g.x + g.w > maxX) maxX = g.x + g.w;',
246
+ ' if (g.y + g.h > maxY) maxY = g.y + g.h;',
247
+ ' }',
248
+ ' var p = 30;',
249
+ ' return {',
250
+ ' minX: minX - p, minY: minY - p, maxX: maxX + p, maxY: maxY + p,',
251
+ ' width: maxX - minX + 2*p, height: maxY - minY + 2*p',
252
+ ' };',
253
+ ' }',
254
+ '',
255
+ ' function getNodeColor(type) { return COLOR_MAP[type] || DEFAULT_COLOR; }',
256
+ '',
257
+ ' function getNodeCSSClass(d) {',
258
+ ' var c = "nr-node";',
259
+ ' if (d.dirty) c += " nr-node-dirty";',
260
+ ' if (d.d) c += " nr-node-disabled";',
261
+ ' return c;',
262
+ ' }',
263
+ '',
264
+ ' function generateLinkPath(origX, origY, destX, destY, sc) {',
265
+ ' var dy = destY - origY, dx = destX - origX;',
266
+ ' var delta = Math.sqrt(dy*dy + dx*dx);',
267
+ ' var NODE_W = DEFAULT_W;',
268
+ ' var NODE_H = DEFAULT_H;',
269
+ ' // Always exit right from source, enter left from target',
270
+ ' var dSign = (dx >= 0) ? 1 : -1;',
271
+ ' var scale = 0.75;',
272
+ ' if (Math.abs(dx) < NODE_W) {',
273
+ ' scale = 0.75 - 0.75 * ((NODE_W - Math.abs(dx)) / NODE_W);',
274
+ ' } else if (dx < 0) {',
275
+ ' scale = 0.4 - 0.2 * (Math.max(0, (NODE_W - Math.min(Math.abs(dx), Math.abs(dy))) / NODE_W));',
276
+ ' }',
277
+ ' // Control points: first goes right, second comes from left',
278
+ ' var cp0x = origX + NODE_W * scale;',
279
+ ' var cp1x = destX - NODE_W * scale;',
280
+ ' if (dx < 0 && Math.abs(dy) > NODE_H) {',
281
+ ' // Vertical L-shaped routing for stacked nodes',
282
+ ' var midY = (origY + destY) / 2;',
283
+ ' return "M " + origX + " " + origY + " C " + cp0x + " " + origY + " " + cp0x + " " + midY + " " + (origX+destX)/2 + " " + midY +',
284
+ ' " L " + (origX+destX)/2 + " " + midY + " C " + cp1x + " " + midY + " " + cp1x + " " + destY + " " + destX + " " + destY;',
285
+ ' }',
286
+ ' // Standard horizontal S-curve: exit right, enter left',
287
+ ' return "M " + origX + " " + origY + " C " + cp0x + " " + origY + " " + cp1x + " " + destY + " " + destX + " " + destY;',
288
+ ' }',
289
+ '',
290
+ ' function escapeHtml(str) {',
291
+ ' var d = document.createElement("div");',
292
+ ' d.textContent = str || "";',
293
+ ' return d.innerHTML;',
294
+ ' }',
295
+ '',
296
+ ' // ---- Tab state ----',
297
+ ' var tabs = extractTabs(ALL_FLOWS);',
298
+ ' var hasTabs = tabs.length > 0;',
299
+ ' var zoomState = {};',
300
+ ' var currentTabId = null;',
301
+ '',
302
+ ' if (tabs.length === 0) {',
303
+ ' var ntn = ALL_FLOWS.filter(function(n) {',
304
+ ' return n.type !== "tab" && n.type !== "group" && n.type !== "junction"',
305
+ ' && Array.isArray(n.wires);',
306
+ ' });',
307
+ ' if (ntn.length > 0) {',
308
+ ' tabs = [{ id: ntn[0].z || "__default__", name: "Flow", disabled: false }];',
309
+ ' hasTabs = false;',
310
+ ' }',
311
+ ' }',
312
+ '',
313
+ ' function buildTabBar() {',
314
+ ' // Remove existing tab elements, keep #tab-bar-actions',
315
+ ' var existing = tabBar.querySelectorAll(".nr-tab");',
316
+ ' for (var k = 0; k < existing.length; k++) { existing[k].remove(); }',
317
+ ' if (!hasTabs) { canvasWrap.classList.remove("with-tabs"); return; }',
318
+ ' canvasWrap.classList.add("with-tabs");',
319
+ ' var actions = document.getElementById("tab-bar-actions");',
320
+ ' for (var i = 0; i < tabs.length; i++) {',
321
+ ' var t = tabs[i];',
322
+ ' var el = document.createElement("div");',
323
+ ' el.className = "nr-tab";',
324
+ ' el.textContent = t.name;',
325
+ ' el.setAttribute("data-tab-id", t.id);',
326
+ ' if (t.id === currentTabId) el.classList.add("active");',
327
+ ' if (t.disabled) el.style.opacity = "0.5";',
328
+ ' el.addEventListener("click", function() {',
329
+ ' switchTab(this.getAttribute("data-tab-id"));',
330
+ ' });',
331
+ ' tabBar.insertBefore(el, actions);',
332
+ ' }',
333
+ ' }',
334
+ '',
335
+ ' // ---- D3 setup ----',
336
+ ' var width = canvasEl.clientWidth;',
337
+ ' var height = canvasEl.clientHeight;',
338
+ ' var svg = d3.select("#canvas").append("svg").attr("width", width).attr("height", height);',
339
+ ' var defs = svg.append("defs");',
340
+ ' defs.append("pattern").attr("id", "grid").attr("width", 20).attr("height", 20)',
341
+ ' .attr("patternUnits", "userSpaceOnUse")',
342
+ ' .append("path").attr("d", "M 20 0 L 0 0 0 20")',
343
+ ' .attr("fill", "none").attr("stroke", "#e0e0e0").attr("stroke-width", 0.5);',
344
+ ' var contentGroup = svg.append("g");',
345
+ ' contentGroup.append("rect").attr("width", 8000).attr("height", 8000).attr("fill", "url(#grid)");',
346
+ ' var groupLayer = contentGroup.append("g").attr("class", "group-layer");',
347
+ ' var linkLayer = contentGroup.append("g").attr("class", "link-layer");',
348
+ ' var nodeLayer = contentGroup.append("g").attr("class", "node-layer");',
349
+ ' var emptyLabel = contentGroup.append("text").attr("class", "nr-empty")',
350
+ ' .attr("text-anchor", "middle").text("No nodes in this flow");',
351
+ '',
352
+ ' var zoom = d3.zoom().scaleExtent([0.1, 2]).on("zoom", function(event) {',
353
+ ' contentGroup.attr("transform", event.transform);',
354
+ ' if (currentTabId) zoomState[currentTabId] = event.transform;',
355
+ ' });',
356
+ ' svg.call(zoom);',
357
+ '',
358
+ ' function render(data) {',
359
+ ' var nodes = data.nodes, groups = data.groups, links = data.links;',
360
+ ' var hasDirty = nodes.some(function(n) { return n.dirty; });',
361
+ ' legend.style.display = hasDirty ? "block" : "none";',
362
+ ' emptyLabel.style("display", nodes.length === 0 && groups.length === 0 ? "block" : "none");',
363
+ '',
364
+ ' var groupSel = groupLayer.selectAll(".nr-group").data(groups, function(d) { return d.id; });',
365
+ ' groupSel.exit().remove();',
366
+ ' var ge = groupSel.enter().append("g").attr("class", "nr-group");',
367
+ ' ge.append("rect").attr("class", "nr-group-rect").attr("rx", 4).attr("ry", 4);',
368
+ ' ge.append("text").attr("font-size", 10).attr("fill", "#999");',
369
+ ' groupSel.merge(ge).each(function(d) {',
370
+ ' var g = d3.select(this);',
371
+ ' g.select("rect").attr("x", d.x).attr("y", d.y).attr("width", d.w).attr("height", d.h);',
372
+ ' g.select("text").attr("x", d.x + 5).attr("y", d.y + 15).text(d.name || "Group");',
373
+ ' });',
374
+ '',
375
+ ' var linkSel = linkLayer.selectAll(".nr-link")',
376
+ ' .data(links, function(d) { return d.source.id + ":" + d.sourcePort + ":" + d.target.id; });',
377
+ ' linkSel.exit().remove();',
378
+ ' var linkEnter = linkSel.enter().append("path").attr("class", "nr-link");',
379
+ ' linkEnter.merge(linkSel)',
380
+ ' .attr("d", function(d) {',
381
+ ' var no = d.source.outputs || 1;',
382
+ ' var py = -((no - 1) / 2) * 13 + 13 * d.sourcePort;',
383
+ ' return generateLinkPath(d.source.x + d.source.w / 2, d.source.y + py,',
384
+ ' d.target.x - d.target.w / 2, d.target.y, 1);',
385
+ ' })',
386
+ ' .classed("nr-link-disabled", function(d) { return d.source.d || d.target.d; });',
387
+ '',
388
+ ' var nodeSel = nodeLayer.selectAll(".nr-node").data(nodes, function(d) { return d.id; });',
389
+ ' nodeSel.exit().remove();',
390
+ ' var ne = nodeSel.enter().append("g")',
391
+ ' .attr("class", function(d) { return getNodeCSSClass(d); })',
392
+ ' .attr("transform", function(d) {',
393
+ ' return "translate(" + (d.x - d.w/2) + "," + (d.y - d.h/2) + ")";',
394
+ ' })',
395
+ ' .on("mouseenter", showTooltip).on("mouseleave", hideTooltip);',
396
+ ' ne.append("rect").attr("class", "nr-node-body").attr("rx", 5).attr("ry", 5)',
397
+ ' .attr("fill", function(d) { return getNodeColor(d.type); });',
398
+ ' ne.append("text").attr("text-anchor", "middle").attr("font-size", 10)',
399
+ ' .attr("fill", "#333").attr("dy", "0.35em");',
400
+ ' nodeSel.merge(ne).each(function(d) {',
401
+ ' var g = d3.select(this);',
402
+ ' g.attr("class", getNodeCSSClass(d));',
403
+ ' g.attr("transform", "translate(" + (d.x - d.w/2) + "," + (d.y - d.h/2) + ")");',
404
+ ' g.select("rect").attr("width", d.w).attr("height", d.h)',
405
+ ' .attr("fill", getNodeColor(d.type));',
406
+ ' g.select("text").attr("x", d.w/2).attr("y", d.h/2)',
407
+ ' .text((d.name || d.type).substring(0, 20));',
408
+ ' if (d.inputs > 0 && g.select(".nr-port-input").empty()) {',
409
+ ' g.append("rect").attr("class", "nr-port-input")',
410
+ ' .attr("x", -5).attr("y", d.h/2 - 4).attr("width", 8).attr("height", 8)',
411
+ ' .attr("rx", 2).attr("fill", "#999");',
412
+ ' }',
413
+ ' g.selectAll(".nr-port-output").remove();',
414
+ ' for (var i = 0; i < (d.outputs || 0); i++) {',
415
+ ' var py = d.h/2 - ((d.outputs - 1)/2)*13 + 13*i - 4;',
416
+ ' g.append("rect").attr("class", "nr-port-output")',
417
+ ' .attr("x", d.w - 3).attr("y", py).attr("width", 8).attr("height", 8)',
418
+ ' .attr("rx", 2).attr("fill", "#999");',
419
+ ' }',
420
+ ' });',
421
+ ' }',
422
+ '',
423
+ ' function fitToView(bb) {',
424
+ ' var cw = bb.width || 800, ch = bb.height || 600;',
425
+ ' var s = Math.min(width / cw, height / ch);',
426
+ ' // Clamp: never smaller than 25%, never larger than 100%',
427
+ ' s = Math.max(0.25, Math.min(1.0, s));',
428
+ ' var tx = (width - cw * s) / 2 - (bb.minX || 0) * s;',
429
+ ' var ty = (height - ch * s) / 2 - (bb.minY || 0) * s;',
430
+ ' svg.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(s));',
431
+ ' }',
432
+ '',
433
+ ' function switchTab(tabId) {',
434
+ ' if (tabId === currentTabId) return;',
435
+ ' currentTabId = tabId;',
436
+ ' var data = buildTabData(ALL_FLOWS, tabId, DIRTY_NODE_IDS);',
437
+ ' var els = tabBar.querySelectorAll(".nr-tab");',
438
+ ' for (var i = 0; i < els.length; i++) {',
439
+ ' els[i].classList.toggle("active", els[i].getAttribute("data-tab-id") === tabId);',
440
+ ' }',
441
+ ' groupLayer.selectAll("*").remove();',
442
+ ' linkLayer.selectAll("*").remove();',
443
+ ' nodeLayer.selectAll("*").remove();',
444
+ ' render(data);',
445
+ ' if (zoomState[tabId]) {',
446
+ ' svg.call(zoom.transform, zoomState[tabId]);',
447
+ ' } else {',
448
+ ' svg.call(zoom.transform, d3.zoomIdentity);',
449
+ ' }',
450
+ ' }',
451
+ '',
452
+ ' function refreshCurrentTab() {',
453
+ ' if (!currentTabId) return;',
454
+ ' var data = buildTabData(ALL_FLOWS, currentTabId, DIRTY_NODE_IDS);',
455
+ ' render(data);',
456
+ ' }',
457
+ '',
458
+ ' function refreshTabDirtyIndicators() {',
459
+ ' var els = tabBar.querySelectorAll(".nr-tab");',
460
+ ' for (var i = 0; i < els.length; i++) {',
461
+ ' var tid = els[i].getAttribute("data-tab-id");',
462
+ ' var data = buildTabData(ALL_FLOWS, tid, DIRTY_NODE_IDS);',
463
+ ' var hasDirty = data.nodes.some(function(n) { return n.dirty; });',
464
+ ' els[i].classList.toggle("dirty", hasDirty);',
465
+ ' }',
466
+ ' }',
467
+ '',
468
+ ' function showTooltip(event, d) {',
469
+ ' tooltip.style.display = "block";',
470
+ ' tooltip.innerHTML =',
471
+ ' "<div class=\\"tt-name\\">" + escapeHtml(d.name || d.type) + "</div>" +',
472
+ ' "<div>Type: " + escapeHtml(d.type) + "</div>" +',
473
+ ' "<div>ID: " + escapeHtml(d.id) + "</div>" +',
474
+ ' (d.dirty ? "<div class=\\"tt-dirty\\">\u26a1 Un-deployed changes</div>" : "") +',
475
+ ' (d.d ? "<div>\u26d4 Disabled</div>" : "");',
476
+ ' tooltip.style.left = (event.pageX + 12) + "px";',
477
+ ' tooltip.style.top = (event.pageY - 10) + "px";',
478
+ ' }',
479
+ '',
480
+ ' function hideTooltip() { tooltip.style.display = "none"; }',
481
+ '',
482
+ ' // ---- WebSocket ----',
483
+ ' var WS_URL = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/staging-ws";',
484
+ ' var ws = null, reconnectDelay = 3000, reconnectTimer = null;',
485
+ '',
486
+ ' function connectWS() {',
487
+ ' try { ws = new WebSocket(WS_URL); } catch(e) { scheduleReconnect(); return; }',
488
+ ' ws.onopen = function() { banner.classList.remove("visible"); reconnectDelay = 3000; };',
489
+ ' ws.onmessage = function(event) {',
490
+ ' try {',
491
+ ' var msg = JSON.parse(event.data);',
492
+ ' if (msg.type === "staging-update") {',
493
+ ' ALL_FLOWS = msg.flows || [];',
494
+ ' DIRTY_NODE_IDS = new Set(msg.dirtyNodeIds || []);',
495
+ ' tabs = extractTabs(ALL_FLOWS);',
496
+ ' if (tabs.length === 0) {',
497
+ ' hasTabs = false;',
498
+ ' if (ALL_FLOWS.length > 0) {',
499
+ ' var ntn2 = ALL_FLOWS.filter(function(n) {',
500
+ ' return n.type !== "tab" && n.type !== "group" && n.type !== "junction"',
501
+ ' && Array.isArray(n.wires);',
502
+ ' });',
503
+ ' if (ntn2.length > 0) tabs = [{ id: ntn2[0].z || "__default__", name: "Flow", disabled: false }];',
504
+ ' }',
505
+ ' } else { hasTabs = true; }',
506
+ ' buildTabBar();',
507
+ ' var exists = tabs.some(function(t) { return t.id === currentTabId; });',
508
+ ' if (!exists && tabs.length > 0) { switchTab(tabs[0].id); }',
509
+ ' else { refreshCurrentTab(); }',
510
+ ' refreshTabDirtyIndicators();',
511
+ ' }',
512
+ ' } catch(err) { console.warn("WS parse error:", err); }',
513
+ ' };',
514
+ ' ws.onclose = function() { banner.classList.add("visible"); scheduleReconnect(); };',
515
+ ' ws.onerror = function() {};',
516
+ ' }',
517
+ '',
518
+ ' function scheduleReconnect() {',
519
+ ' if (reconnectTimer) return;',
520
+ ' reconnectTimer = setTimeout(function() {',
521
+ ' reconnectTimer = null;',
522
+ ' connectWS();',
523
+ ' reconnectDelay = Math.min(reconnectDelay * 1.5, 30000);',
524
+ ' }, reconnectDelay);',
525
+ ' }',
526
+ '',
527
+ ' // ---- Refresh button ----',
528
+ ' function applySnapshot(data) {',
529
+ ' ALL_FLOWS = data.flows || [];',
530
+ ' DIRTY_NODE_IDS = new Set(data.dirtyNodeIds || []);',
531
+ ' tabs = extractTabs(ALL_FLOWS);',
532
+ ' if (tabs.length === 0) {',
533
+ ' hasTabs = false;',
534
+ ' if (ALL_FLOWS.length > 0) {',
535
+ ' var ntn3 = ALL_FLOWS.filter(function(n) { return n.type !== "tab" && n.type !== "group" && n.type !== "junction" && Array.isArray(n.wires); });',
536
+ ' if (ntn3.length > 0) tabs = [{ id: ntn3[0].z || "__default__", name: "Flow", disabled: false }];',
537
+ ' }',
538
+ ' } else { hasTabs = true; }',
539
+ ' buildTabBar();',
540
+ ' var exists = tabs.some(function(t) { return t.id === currentTabId; });',
541
+ ' if (!exists && tabs.length > 0) { switchTab(tabs[0].id); }',
542
+ ' else { refreshCurrentTab(); }',
543
+ ' refreshTabDirtyIndicators();',
544
+ ' }',
545
+ '',
546
+ ' btnRefresh.addEventListener("click", function() {',
547
+ ' if (DIRTY_NODE_IDS.size > 0) {',
548
+ ' if (!confirm("Warning: You have un-deployed changes. Refreshing will permanently discard all pending changes. Are you sure you want to continue?")) {',
549
+ ' return;',
550
+ ' }',
551
+ ' }',
552
+ ' btnRefresh.classList.add("loading");',
553
+ ' fetch(\"/staging-refresh\", { method: \"POST\" })',
554
+ ' .then(function(r) { return r.json(); })',
555
+ ' .then(function(data) { applySnapshot(data); btnRefresh.classList.remove("loading"); })',
556
+ ' .catch(function(err) { console.warn("Refresh failed:", err); btnRefresh.classList.remove("loading"); });',
557
+ ' });',
558
+ '',
559
+ ' // ---- Init ----',
560
+ ' buildTabBar();',
561
+ ' if (tabs.length > 0) { switchTab(tabs[0].id); }',
562
+ ' else { currentTabId = "__empty__"; emptyLabel.style("display", "block"); }',
563
+ ' connectWS();',
564
+ '})();',
565
+ '</script>',
566
+ '</body>',
567
+ '</html>'
568
+ ].join('\n');
569
+
570
+ return html;
571
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Unified Staging Renderer
3
+ *
4
+ * Entry point for rendering the staging workspace into SVG, HTML, or Mermaid
5
+ * formats. Consumes the internal intermediate representation (IR) and delegates
6
+ * to format-specific builders.
7
+ *
8
+ * @module renderer
9
+ */
10
+
11
+ import { buildIR } from './ir-builder.js';
12
+ import { buildSVG } from './svg-builder.js';
13
+ import { buildHTML } from './html-builder.js';
14
+ import { buildMermaid } from './mermaid-builder.js';
15
+
16
+ /**
17
+ * Render the staging workspace in the requested format.
18
+ *
19
+ * @param {object[]} flows - Raw Node-RED flows array (from StagingStore)
20
+ * @param {object} options - Rendering options
21
+ * @param {'svg'|'html'|'mermaid'} options.format - Output format
22
+ * @param {string} [options.flowId] - Filter to a single flow tab/subflow
23
+ * @param {boolean} [options.highlightDirty=true] - Highlight un-deployed nodes
24
+ * @param {Set<string>} [options.dirtyNodeIds] - Set of dirty node IDs
25
+ * @param {Set<string>} [options.dirtyFlowIds] - Set of dirty flow IDs
26
+ * @returns {{ svg?: string, html?: string, mermaid?: string }}
27
+ * @throws {Error} If flowId is provided but not found
28
+ */
29
+ export function renderStaging(flows, options = {}) {
30
+ const {
31
+ format = 'svg',
32
+ flowId,
33
+ highlightDirty = true,
34
+ dirtyNodeIds = new Set(),
35
+ dirtyFlowIds = new Set(),
36
+ } = options;
37
+
38
+ // Build the intermediate representation
39
+ const ir = buildIR(flows, { flowId, highlightDirty, dirtyNodeIds, dirtyFlowIds });
40
+
41
+ switch (format) {
42
+ case 'svg':
43
+ return { svg: buildSVG(ir) };
44
+ case 'html':
45
+ return { html: buildHTML(flows, { highlightDirty, dirtyNodeIds, dirtyFlowIds }) };
46
+ case 'mermaid':
47
+ return { mermaid: buildMermaid(ir) };
48
+ default:
49
+ throw new Error(`Unknown format: ${format}. Supported: svg, html, mermaid`);
50
+ }
51
+ }