Add the Graceful Shutdown UI

pull/5346/head
GogoVega 2025-11-06 21:38:28 +01:00
parent af6d7c2b9b
commit 5113851189
No known key found for this signature in database
GPG Key ID: E1E048B63AC5AC2B
8 changed files with 326 additions and 24 deletions

View File

@ -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",

View File

@ -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) {

View File

@ -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;
}

View File

@ -0,0 +1,269 @@
; (function () {
const tabcontent = `
<div class="form-row">
<label for="node-input-timeout"><i class="fa fa-clock-o"></i> <span data-i18n="node-red:exec.label.timeout"></span></label>
<input type="text" id="node-input-timeout" placeholder="10" style="width: 70px; margin-right: 5px;"> <span data-i18n="node-red:inject.seconds"></span>
</div>
<div class="form-row node-input-scope-row" style="margin-bottom: 0px;">
<input type="checkbox" id="node-input-failFast" style="display: inline-block; width: auto; vertical-align: top; margin-left: 30px; margin-right: 5px;">
<label for="node-input-failFast" style="width: auto;"><span data-i18n="editor.failFast"></span></label>
</div>
<div class="form-row node-input-scope-row" style="min-height: 100px; height: 10%;">
<div id="node-input-scope-target-container-div"></div>
</div>
<div class="form-row">
<label style="width: auto" data-i18n="editor:editor.initiatorNodes""></label>
</div>
<div class="form-row node-input-target-row">
<button type="button" id="node-input-initiator-target-select" class="red-ui-button" data-i18n="node-red:common.label.selectNodes"></button>
</div>
<div class="form-row node-input-target-row node-input-initiator-list-row" style="position: relative; min-height: 200px;">
<div style="position: absolute; top: -30px; right: 0;"><input type="text" id="node-input-initiator-target-filter"></div>
<div id="node-input-initiator-target-container-div"></div>
</div>`;
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 = $('<form class="dialog-form form-horizontal" autocomplete="off"></form>').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 {
$("<p>Graceful shutdown disabled</p>").appendTo(dialogForm);
}
},
close: function () { },
show: function () { },
resize: function (_size) {
if (this._resize) {
this._resize();
}
},
/** @type {(editState: { changes?: Record<string, unknown>, 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 = $('<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);
}
})();

View File

@ -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);

View File

@ -84,6 +84,9 @@ RED.workspaces = (function() {
info: "",
label: RED._('workspace.defaultName',{number:workspaceIndex}),
env: [],
failFast: false,
timeout: 10000,
initiatorNodes: [],
hideable: true,
};
if (!skipHistoryEntry) {

View File

@ -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 = {}

View File

@ -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;