From 5113851189f26be9348d8ca81c4a653ee2f07050 Mon Sep 17 00:00:00 2001 From: GogoVega <92022724+GogoVega@users.noreply.github.com> Date: Thu, 6 Nov 2025 21:38:28 +0100 Subject: [PATCH] Add the Graceful Shutdown UI --- .../editor-client/locales/en-US/editor.json | 5 +- .../@node-red/editor-client/src/js/nodes.js | 13 +- .../editor-client/src/js/ui/editor.js | 9 + .../src/js/ui/editors/panes/shutdown.js | 269 ++++++++++++++++++ .../editor-client/src/js/ui/subflow.js | 10 +- .../editor-client/src/js/ui/workspaces.js | 3 + .../@node-red/registry/lib/subflow.js | 5 +- .../@node-red/runtime/lib/flows/index.js | 36 +-- 8 files changed, 326 insertions(+), 24 deletions(-) create mode 100644 packages/node_modules/@node-red/editor-client/src/js/ui/editors/panes/shutdown.js diff --git a/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json b/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json index c1c9316d8..aa123b824 100644 --- a/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json +++ b/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json @@ -476,6 +476,8 @@ "inputType": "Input type", "selectType": "select types...", "loadCredentials": "Loading node credentials", + "initiatorNodes": "During shutdown, start by stopping message initiator nodes:", + "failFast": "Cancel shutdown of listed flows if this flow fails to stop", "inputs": { "input": "input", "select": "select", @@ -1238,7 +1240,8 @@ "description": "Description", "appearance": "Appearance", "preview": "UI Preview", - "defaultValue": "Default value" + "defaultValue": "Default value", + "shutdown": "Graceful Shutdown" }, "tourGuide": { "takeATour": "Take a tour", diff --git a/packages/node_modules/@node-red/editor-client/src/js/nodes.js b/packages/node_modules/@node-red/editor-client/src/js/nodes.js index 4a114560b..1db933834 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/nodes.js +++ b/packages/node_modules/@node-red/editor-client/src/js/nodes.js @@ -112,7 +112,10 @@ RED.nodes = (function() { disabled: {value: false}, locked: {value: false}, info: {value: ""}, - env: {value: []} + env: {value: []}, + failFast: { value: false }, + timeout: { value: 10000 }, + initiatorNodes: { value: [] }, } }; @@ -1141,7 +1144,10 @@ RED.nodes = (function() { } else { return errors } - }} + }}, + failFast: { value: false }, + timeout: { value: 10000 }, + initiatorNodes: { value: [] }, }, icon: function() { return sf.icon||"subflow.svg" }, category: sf.category || "subflows", @@ -1457,6 +1463,9 @@ RED.nodes = (function() { node.in = []; node.out = []; node.env = n.env; + node.failFast = n.failFast; + node.timeout = n.timeout; + node.initiatorNodes = n.initiatorNodes; node.meta = n.meta; if (exportCreds) { diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/editor.js b/packages/node_modules/@node-red/editor-client/src/js/ui/editor.js index f804e6de2..f6f52ccf2 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/editor.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/editor.js @@ -1963,6 +1963,11 @@ RED.editor = (function() { 'editor-tab-description', 'editor-tab-appearance' ]; + + if (RED.settings.gracefulShutdownEnabled) { + nodeEditPanes.splice(1, 0, 'editor-tab-shutdown'); + } + prepareEditDialog(trayBody, nodeEditPanes, subflow, subflow._def, "subflow-input", defaultTab, function (_activeEditPanes) { activeEditPanes = _activeEditPanes; trayBody.i18n(); @@ -2215,6 +2220,10 @@ RED.editor = (function() { 'editor-tab-envProperties' ]; + if (RED.settings.gracefulShutdownEnabled) { + nodeEditPanes.splice(1, 0, 'editor-tab-shutdown'); + } + if (!workspace.hasOwnProperty("disabled")) { workspace.disabled = false; } diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/editors/panes/shutdown.js b/packages/node_modules/@node-red/editor-client/src/js/ui/editors/panes/shutdown.js new file mode 100644 index 000000000..1335d965b --- /dev/null +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/editors/panes/shutdown.js @@ -0,0 +1,269 @@ +; (function () { + const tabcontent = ` +
+ + +
+
+ + +
+
+
+
+
+ +
+
+ +
+
+
+
+
`; + + RED.editor.registerEditPane("editor-tab-shutdown", function (node) { + return { + label: RED._("editor-tab.shutdown"), + name: RED._("editor-tab.shutdown"), + iconClass: "fa fa-power-off", + + create: function (container) { + const dialogForm = $('
').appendTo(container); + + if (RED.settings.gracefulShutdownEnabled) { + $(tabcontent).appendTo(dialogForm); + buildScopeForm(dialogForm, node); + buildInitiatorForm(dialogForm, node); + $("#node-input-timeout").spinner({ min: 1 }).val((node.timeout || 10000) / 1000); + $("#node-input-failFast").prop("checked", node.failFast || false); + this._resize = function () { + const rows = $(dialogForm).find(">div:not(.node-input-initiator-list-row)"); + let height = $(dialogForm).height(); + for (let i = 0; i < rows.length; i++) { + height -= $(rows[i]).outerHeight(true); + } + $(dialogForm).find(">div.node-input-initiator-list-row").css("height", height + "px"); + }; + } else { + $("

Graceful shutdown disabled

").appendTo(dialogForm); + } + }, + close: function () { }, + show: function () { }, + resize: function (_size) { + if (this._resize) { + this._resize(); + } + }, + /** @type {(editState: { changes?: Record, changed?: boolean }) => void} */ + apply: function (editState) { + const failFast = $("#node-input-failFast").prop("checked"); + if (node.failFast != failFast) { + editState.changes = editState.changes || {}; + editState.changes.failFast = node.failFast; + editState.changed = true; + node.failFast = failFast; + } + + let timeout = parseFloat($("#node-input-timeout").val() || "10") * 1000; + if (Number.isNaN(timeout)) { + timeout = 10000; + } + if (node.timeout !== timeout) { + editState.changes = editState.changes || {}; + editState.changes.timeout = node.timeout; + editState.changed = true; + node.timeout = timeout; + } + + const initiatorNodes = $("#node-input-initiator-target-container-div").treeList("selected").map((i) => i.node.id); + if (JSON.stringify(initiatorNodes) !== JSON.stringify(node.initialNodes || [])) { + editState.changes = editState.changes || {}; + editState.changes.initiatorNodes = node.initiatorNodes; + editState.changed = true; + node.initiatorNodes = initiatorNodes; + } + } + } + }); + + /** @type {(node: object) => JQuery} */ + function getNodeLabel(node) { + var div = $('
',{class:"red-ui-node-list-item red-ui-info-outline-item"}); + RED.utils.createNodeIcon(node, true).appendTo(div); + div.find(".red-ui-node-label").addClass("red-ui-info-outline-item-label") + return div; + } + + /** @type {(container: JQuery, node: object) => void} */ + function buildScopeForm(container, node) { + const scope = getScope(node); + + if (!scope.length) { + $(container).find(".node-input-scope-row").hide(); + return; + } + + const items = []; + const flowItemMap = {}; + for (const id of scope) { + if (id === node.id) { + continue; + } + + const isSuflow = id.startsWith("subflow:"); + const workspace = isSuflow ? RED.nodes.subflow(id.substring(8)) : RED.nodes.workspace(id); + if (workspace) { + flowItemMap[workspace.id] = { + element: getNodeLabel(workspace), + selected: true, + }; + + items.push(flowItemMap[workspace.id]); + } + } + + const dirList = $(container).find("#node-input-scope-target-container-div"); + dirList.css({ width: "100%", height: "100%" }).treeList({ multi: true, data: items }); + } + + /** @type {(container: JQuery, node: object) => void} */ + function buildInitiatorForm(container, node) { + const dirList = $(container).find("#node-input-initiator-target-container-div"); + + // We assume that a message initiator node must have at least one output + const nodeFilter = function (n) { + if (n.type.startsWith("link ")) { + // Link nodes transmits messages, but does not generate them. + return false; + } + if (n.hasOwnProperty("outputs")) { + return n.outputs > 0; + } + return true; + }; + const candidateNodes = RED.nodes.filterNodes({ z: node.id }).filter(nodeFilter); + const search = $(container).find("#node-input-initiator-target-filter").searchBox({ + style: "compact", + delay: 300, + change: function () { + const val = $(this).val().trim().toLowerCase(); + if (val === "") { + dirList.treeList("filter", null); + search.searchBox("count", ""); + } else { + const count = dirList.treeList("filter", function (item) { + return item.label.toLowerCase().indexOf(val) > -1 || item.node.type.toLowerCase().indexOf(val) > -1 + }); + search.searchBox("count", count + " / " + candidateNodes.length); + } + } + }); + + dirList.css({ width: "100%", height: "100%" }) + .treeList({ multi: true }).on("treelistitemmouseover", function (e, item) { + item.node.highlighted = true; + item.node.dirty = true; + RED.view.redraw(); + }).on("treelistitemmouseout", function (e, item) { + item.node.highlighted = false; + item.node.dirty = true; + RED.view.redraw(); + }); + + const items = []; + const nodeItemMap = {}; + const scope = node.initiatorNodes || []; + candidateNodes.forEach(function (n) { + const isChecked = scope.indexOf(n.id) !== -1; + const nodeDef = RED.nodes.getType(n.type); + let label, sublabel; + if (nodeDef) { + const l = nodeDef.label; + label = (typeof l === "function" ? l.call(n) : l) || ""; + sublabel = n.type; + if (sublabel.indexOf("subflow:") === 0) { + const subflowId = sublabel.substring(8); + const subflow = RED.nodes.subflow(subflowId); + sublabel = "subflow : " + subflow.name; + } + } + if (!nodeDef || !label) { + label = n.type; + } + nodeItemMap[n.id] = { + node: n, + label: label, + sublabel: sublabel, + selected: isChecked, + checkbox: true + }; + + items.push(nodeItemMap[n.id]); + }); + + dirList.treeList("data", items); + + $(container).find("#node-input-initiator-target-select").on("click", function (event) { + event.preventDefault(); + const preselected = dirList.treeList("selected").map((item) => item.node.id); + RED.tray.hide(); + RED.view.selectNodes({ + selected: preselected, + onselect: function (selection) { + RED.tray.show(); + const newlySelected = {}; + selection.forEach(function (n) { + newlySelected[n.id] = true; + if (nodeItemMap[n.id]) { + nodeItemMap[n.id].treeList.select(true); + } + }); + preselected.forEach(function (id) { + if (!newlySelected[id]) { + nodeItemMap[id].treeList.select(false); + } + }); + }, + oncancel: function () { + RED.tray.show(); + }, + filter: nodeFilter, + }); + }); + } + + /** @type {(flow: object) => string[]} */ + function getScope(flow) { + const activeWorkspace = flow.id; + /** @type {(node: object) => boolean} */ + const nodeFilter = (n) => n.type.startsWith("link ") || n.type.startsWith("subflow:"); + const nodes = RED.nodes.filterNodes({ z: activeWorkspace }).filter(nodeFilter); + const seen = new Set(); + const scope = { [activeWorkspace]: true }; + seen.add(activeWorkspace); + while (nodes.length) { + const node = nodes.pop(); + if (seen.has(node.id)) { + continue; + } + seen.add(node.id); + if (node.links) { + node.links.forEach((id) => { + const link = RED.nodes.node(id); + if (link && !scope[link.z]) { + scope[link.z] = true; + nodes.push(...RED.nodes.filterNodes({ z: link.z }).filter(nodeFilter)); + } + }) + } else if (node.type.startsWith("subflow:") && !scope[node.type]) { + scope[node.type] = true; + nodes.push(...RED.nodes.filterNodes({ z: node.type.substring(8) }).filter(nodeFilter)); + } + } + delete scope[activeWorkspace]; + return Object.keys(scope); + } + +})(); diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/subflow.js b/packages/node_modules/@node-red/editor-client/src/js/ui/subflow.js index 3e1b9a410..ae4243a09 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/subflow.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/subflow.js @@ -608,7 +608,10 @@ RED.subflow = (function() { name:name, info:"", in: [], - out: [] + out: [], + failFast: { value: false }, + timeout: { value: 10000 }, + initiatorNodes: { value: [] }, }; RED.nodes.addSubflow(subflow); RED.history.push({ @@ -777,7 +780,10 @@ RED.subflow = (function() { i:index, id:RED.nodes.id(), wires:[{id:v.source.id,port:v.sourcePort}] - }}) + }}), + failFast: { value: false }, + timeout: { value: 10000 }, + initiatorNodes: { value: [] }, }; RED.nodes.addSubflow(subflow); diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/workspaces.js b/packages/node_modules/@node-red/editor-client/src/js/ui/workspaces.js index 78e1399cd..da720a418 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/workspaces.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/workspaces.js @@ -84,6 +84,9 @@ RED.workspaces = (function() { info: "", label: RED._('workspace.defaultName',{number:workspaceIndex}), env: [], + failFast: false, + timeout: 10000, + initiatorNodes: [], hideable: true, }; if (!skipHistoryEntry) { diff --git a/packages/node_modules/@node-red/registry/lib/subflow.js b/packages/node_modules/@node-red/registry/lib/subflow.js index 39fe083ab..29b786592 100644 --- a/packages/node_modules/@node-red/registry/lib/subflow.js +++ b/packages/node_modules/@node-red/registry/lib/subflow.js @@ -16,7 +16,10 @@ function generateSubflowConfig(subflow) { const icon = subflow.icon || "arrow-in.svg"; const defaults = { - name: {value: ""} + name: {value: ""}, + failFast: { value: false }, + timeout: { value: 10000 }, + initiatorNodes: { value: [] }, } const credentials = {} diff --git a/packages/node_modules/@node-red/runtime/lib/flows/index.js b/packages/node_modules/@node-red/runtime/lib/flows/index.js index f97b15b5e..e7c332bb3 100644 --- a/packages/node_modules/@node-red/runtime/lib/flows/index.js +++ b/packages/node_modules/@node-red/runtime/lib/flows/index.js @@ -604,14 +604,14 @@ async function addFlow(flow, user) { if (flow.hasOwnProperty('env')) { tabNode.env = flow.env; } - if (flow.hasOwnProperty('shutdownScope')) { - tabNode.shutdownScope = flow.shutdownScope; + if (flow.hasOwnProperty('failFast')) { + tabNode.failFast = flow.failFast; } - if (flow.hasOwnProperty('shutdownTimeout')) { - tabNode.shutdownTimeout = flow.shutdownTimeout; + if (flow.hasOwnProperty('timeout')) { + tabNode.timeout = flow.timeout; } - if (flow.hasOwnProperty('messageInitiatorNodes')) { - tabNode.messageInitiatorNodes = flow.messageInitiatorNodes; + if (flow.hasOwnProperty('initiatorNodes')) { + tabNode.initiatorNodes = flow.initiatorNodes; } var nodes = [tabNode]; @@ -676,14 +676,14 @@ function getFlow(id) { if (flow.hasOwnProperty('env')) { result.env = flow.env; } - if (flow.hasOwnProperty('shutdownScope')) { - result.shutdownScope = flow.shutdownScope; + if (flow.hasOwnProperty('failFast')) { + result.failFast = flow.failFast; } - if (flow.hasOwnProperty('shutdownTimeout')) { - result.shutdownTimeout = flow.shutdownTimeout; + if (flow.hasOwnProperty('timeout')) { + result.timeout = flow.timeout; } - if (flow.hasOwnProperty('messageInitiatorNodes')) { - result.messageInitiatorNodes = flow.messageInitiatorNodes; + if (flow.hasOwnProperty('initiatorNodes')) { + result.initiatorNodes = flow.initiatorNodes; } if (id !== 'global') { result.nodes = []; @@ -813,14 +813,14 @@ async function updateFlow(id,newFlow, user) { if (newFlow.hasOwnProperty('env')) { tabNode.env = newFlow.env; } - if (flow.hasOwnProperty('shutdownScope')) { - tabNode.shutdownScope = flow.shutdownScope; + if (flow.hasOwnProperty('failFast')) { + tabNode.failFast = flow.failFast; } - if (flow.hasOwnProperty('shutdownTimeout')) { - tabNode.shutdownTimeout = flow.shutdownTimeout; + if (flow.hasOwnProperty('timeout')) { + tabNode.timeout = flow.timeout; } - if (flow.hasOwnProperty('messageInitiatorNodes')) { - tabNode.messageInitiatorNodes = flow.messageInitiatorNodes; + if (flow.hasOwnProperty('initiatorNodes')) { + tabNode.initiatorNodes = flow.initiatorNodes; } if (newFlow.hasOwnProperty('credentials')) { tabNode.credentials = newFlow.credentials;