diff --git a/Gruntfile.js b/Gruntfile.js
index 979b38051..af0aa0e95 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -142,6 +142,7 @@ module.exports = function(grunt) {
"packages/node_modules/@node-red/editor-client/src/js/settings.js",
"packages/node_modules/@node-red/editor-client/src/js/user.js",
"packages/node_modules/@node-red/editor-client/src/js/comms.js",
+ "packages/node_modules/@node-red/editor-client/src/js/runtime.js",
"packages/node_modules/@node-red/editor-client/src/js/text/bidi.js",
"packages/node_modules/@node-red/editor-client/src/js/text/format.js",
"packages/node_modules/@node-red/editor-client/src/js/ui/state.js",
diff --git a/packages/node_modules/@node-red/editor-api/lib/admin/flows.js b/packages/node_modules/@node-red/editor-api/lib/admin/flows.js
index 11b30e446..611d9c2ca 100644
--- a/packages/node_modules/@node-red/editor-api/lib/admin/flows.js
+++ b/packages/node_modules/@node-red/editor-api/lib/admin/flows.js
@@ -68,5 +68,28 @@ module.exports = {
}).catch(function(err) {
apiUtils.rejectHandler(req,res,err);
})
+ },
+ getState: function(req,res) {
+ const opts = {
+ user: req.user,
+ req: apiUtils.getRequestLogObject(req)
+ }
+ runtimeAPI.flows.getState(opts).then(function(result) {
+ res.json(result);
+ }).catch(function(err) {
+ apiUtils.rejectHandler(req,res,err);
+ })
+ },
+ postState: function(req,res) {
+ const opts = {
+ user: req.user,
+ requestedState: req.get("Node-RED-Flow-Run-State-Change")||"",
+ req: apiUtils.getRequestLogObject(req)
+ }
+ runtimeAPI.flows.setState(opts).then(function(result) {
+ res.json(result);
+ }).catch(function(err) {
+ apiUtils.rejectHandler(req,res,err);
+ })
}
}
diff --git a/packages/node_modules/@node-red/editor-api/lib/admin/index.js b/packages/node_modules/@node-red/editor-api/lib/admin/index.js
index 87fd0dec0..078779f5a 100644
--- a/packages/node_modules/@node-red/editor-api/lib/admin/index.js
+++ b/packages/node_modules/@node-red/editor-api/lib/admin/index.js
@@ -54,6 +54,12 @@ module.exports = {
adminApp.get("/flows",needsPermission("flows.read"),flows.get,apiUtil.errorHandler);
adminApp.post("/flows",needsPermission("flows.write"),flows.post,apiUtil.errorHandler);
+ // Flows/state
+ adminApp.get("/flows/state", needsPermission("flows.read"), flows.getState, apiUtil.errorHandler);
+ if (!settings.runtimeState || settings.runtimeState.enabled !== false) {
+ adminApp.post("/flows/state", needsPermission("flows.write"), flows.postState, apiUtil.errorHandler);
+ }
+
// Flow
adminApp.get("/flow/:id",needsPermission("flows.read"),flow.get,apiUtil.errorHandler);
adminApp.post("/flow",needsPermission("flows.write"),flow.post,apiUtil.errorHandler);
diff --git a/packages/node_modules/@node-red/editor-client/src/images/start.svg b/packages/node_modules/@node-red/editor-client/src/images/start.svg
new file mode 100644
index 000000000..9623be86c
--- /dev/null
+++ b/packages/node_modules/@node-red/editor-client/src/images/start.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/packages/node_modules/@node-red/editor-client/src/images/stop.svg b/packages/node_modules/@node-red/editor-client/src/images/stop.svg
new file mode 100644
index 000000000..13b1a945a
--- /dev/null
+++ b/packages/node_modules/@node-red/editor-client/src/images/stop.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/packages/node_modules/@node-red/editor-client/src/js/red.js b/packages/node_modules/@node-red/editor-client/src/js/red.js
index 57d2d1e33..939c32f2e 100644
--- a/packages/node_modules/@node-red/editor-client/src/js/red.js
+++ b/packages/node_modules/@node-red/editor-client/src/js/red.js
@@ -297,6 +297,10 @@ var RED = (function() {
// handled below
return;
}
+ if (notificationId === "flows-run-state") {
+ // handled in editor-client/src/js/runtime.js
+ return;
+ }
if (notificationId === "project-update") {
loader.start(RED._("event.loadingProject"), 0);
RED.nodes.clear();
@@ -747,6 +751,7 @@ var RED = (function() {
RED.keyboard.init(buildMainMenu);
RED.nodes.init();
+ RED.runtime.init()
RED.comms.connect();
$("#red-ui-main-container").show();
diff --git a/packages/node_modules/@node-red/editor-client/src/js/runtime.js b/packages/node_modules/@node-red/editor-client/src/js/runtime.js
new file mode 100644
index 000000000..939517877
--- /dev/null
+++ b/packages/node_modules/@node-red/editor-client/src/js/runtime.js
@@ -0,0 +1,53 @@
+RED.runtime = (function() {
+ let state = ""
+ let settings = {ui: true, enabled: true};
+ const STOPPED = "stopped"
+ const STARTED = "started"
+ return {
+ init: function() {
+ // refresh the current runtime status from server
+ settings = RED.settings.runtimeState;
+ RED.runtime.requestState()
+
+ // {id:"flows-run-state", started: false, state: "stopped", retain:true}
+ RED.comms.subscribe("notification/flows-run-state",function(topic,msg) {
+ RED.events.emit("flows-run-state",msg);
+ RED.runtime.updateState(msg.state);
+ });
+ },
+ get state() {
+ return state
+ },
+ get started() {
+ return state === STARTED
+ },
+ get states() {
+ return { STOPPED, STARTED }
+ },
+ updateState: function(newState) {
+ state = newState;
+ // disable pointer events on node buttons (e.g. inject/debug nodes)
+ $(".red-ui-flow-node-button").toggleClass("red-ui-flow-node-button-stopped", state === STOPPED)
+ // show/hide Start/Stop based on current state
+ if(!RED.settings.runtimeState || RED.settings.runtimeState.ui !== false) {
+ RED.menu.setVisible("deploymenu-item-runtime-stop", state === STARTED)
+ RED.menu.setVisible("deploymenu-item-runtime-start", state === STOPPED)
+ }
+ },
+ requestState: function(callback) {
+ $.ajax({
+ headers: {
+ "Accept":"application/json"
+ },
+ cache: false,
+ url: 'flows/state',
+ success: function(data) {
+ RED.runtime.updateState(data.state)
+ if(callback) {
+ callback(data.state)
+ }
+ }
+ });
+ }
+ }
+})()
\ No newline at end of file
diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/common/menu.js b/packages/node_modules/@node-red/editor-client/src/js/ui/common/menu.js
index 417189b33..f42da5830 100644
--- a/packages/node_modules/@node-red/editor-client/src/js/ui/common/menu.js
+++ b/packages/node_modules/@node-red/editor-client/src/js/ui/common/menu.js
@@ -139,6 +139,9 @@ RED.menu = (function() {
if (opt.disabled) {
item.addClass("disabled");
}
+ if (opt.visible === false) {
+ item.addClass("hide");
+ }
}
@@ -249,6 +252,14 @@ RED.menu = (function() {
}
}
+ function setVisible(id,state) {
+ if (!state) {
+ $("#"+id).parent().addClass("hide");
+ } else {
+ $("#"+id).parent().removeClass("hide");
+ }
+ }
+
function addItem(id,opt) {
var item = createMenuItem(opt);
if (opt !== null && opt.group) {
@@ -305,6 +316,7 @@ RED.menu = (function() {
isSelected: isSelected,
toggleSelected: toggleSelected,
setDisabled: setDisabled,
+ setVisible: setVisible,
addItem: addItem,
removeItem: removeItem,
setAction: setAction,
diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/deploy.js b/packages/node_modules/@node-red/editor-client/src/js/ui/deploy.js
index 5b73ed271..69460f8cf 100644
--- a/packages/node_modules/@node-red/editor-client/src/js/ui/deploy.js
+++ b/packages/node_modules/@node-red/editor-client/src/js/ui/deploy.js
@@ -63,16 +63,18 @@ RED.deploy = (function() {
''+
''+
'').prependTo(".red-ui-header-toolbar");
- RED.menu.init({id:"red-ui-header-button-deploy-options",
- options: [
- {id:"deploymenu-item-full",toggle:"deploy-type",icon:"red/images/deploy-full.svg",label:RED._("deploy.full"),sublabel:RED._("deploy.fullDesc"),selected: true, onselect:function(s) { if(s){changeDeploymentType("full")}}},
- {id:"deploymenu-item-flow",toggle:"deploy-type",icon:"red/images/deploy-flows.svg",label:RED._("deploy.modifiedFlows"),sublabel:RED._("deploy.modifiedFlowsDesc"), onselect:function(s) {if(s){changeDeploymentType("flows")}}},
- {id:"deploymenu-item-node",toggle:"deploy-type",icon:"red/images/deploy-nodes.svg",label:RED._("deploy.modifiedNodes"),sublabel:RED._("deploy.modifiedNodesDesc"),onselect:function(s) { if(s){changeDeploymentType("nodes")}}},
- null,
- {id:"deploymenu-item-reload", icon:"red/images/deploy-reload.svg",label:RED._("deploy.restartFlows"),sublabel:RED._("deploy.restartFlowsDesc"),onselect:"core:restart-flows"},
-
- ]
- });
+ const mainMenuItems = [
+ {id:"deploymenu-item-full",toggle:"deploy-type",icon:"red/images/deploy-full.svg",label:RED._("deploy.full"),sublabel:RED._("deploy.fullDesc"),selected: true, onselect:function(s) { if(s){changeDeploymentType("full")}}},
+ {id:"deploymenu-item-flow",toggle:"deploy-type",icon:"red/images/deploy-flows.svg",label:RED._("deploy.modifiedFlows"),sublabel:RED._("deploy.modifiedFlowsDesc"), onselect:function(s) {if(s){changeDeploymentType("flows")}}},
+ {id:"deploymenu-item-node",toggle:"deploy-type",icon:"red/images/deploy-nodes.svg",label:RED._("deploy.modifiedNodes"),sublabel:RED._("deploy.modifiedNodesDesc"),onselect:function(s) { if(s){changeDeploymentType("nodes")}}},
+ null
+ ]
+ if(!RED.settings.runtimeState || RED.settings.runtimeState.ui !== false) {
+ mainMenuItems.push({id:"deploymenu-item-runtime-start", icon:"red/images/start.svg",label:"Start"/*RED._("deploy.startFlows")*/,sublabel:"Start Flows" /*RED._("deploy.startFlowsDesc")*/,onselect:"core:start-flows", visible:false})
+ mainMenuItems.push({id:"deploymenu-item-runtime-stop", icon:"red/images/stop.svg",label:"Stop"/*RED._("deploy.startFlows")*/,sublabel:"Stop Flows" /*RED._("deploy.startFlowsDesc")*/,onselect:"core:stop-flows", visible:false})
+ }
+ mainMenuItems.push({id:"deploymenu-item-reload", icon:"red/images/deploy-reload.svg",label:RED._("deploy.restartFlows"),sublabel:RED._("deploy.restartFlowsDesc"),onselect:"core:restart-flows"})
+ RED.menu.init({id:"red-ui-header-button-deploy-options", options: mainMenuItems });
} else if (type == "simple") {
var label = options.label || RED._("deploy.deploy");
var icon = 'red/images/deploy-full-o.svg';
@@ -100,6 +102,8 @@ RED.deploy = (function() {
RED.actions.add("core:deploy-flows",save);
if (type === "default") {
+ RED.actions.add("core:stop-flows",function() { stopStartFlows("stop") });
+ RED.actions.add("core:start-flows",function() { stopStartFlows("start") });
RED.actions.add("core:restart-flows",restart);
RED.actions.add("core:set-deploy-type-to-full",function() { RED.menu.setSelected("deploymenu-item-full",true);});
RED.actions.add("core:set-deploy-type-to-modified-flows",function() { RED.menu.setSelected("deploymenu-item-flow",true); });
@@ -270,18 +274,74 @@ RED.deploy = (function() {
function sanitize(html) {
return html.replace(/&/g,"&").replace(//g,">")
}
- function restart() {
- var startTime = Date.now();
- $(".red-ui-deploy-button-content").css('opacity',0);
- $(".red-ui-deploy-button-spinner").show();
- var deployWasEnabled = !$("#red-ui-header-button-deploy").hasClass("disabled");
- $("#red-ui-header-button-deploy").addClass("disabled");
- deployInflight = true;
+
+ function shadeShow() {
$("#red-ui-header-shade").show();
$("#red-ui-editor-shade").show();
$("#red-ui-palette-shade").show();
$("#red-ui-sidebar-shade").show();
-
+ }
+ function shadeHide() {
+ $("#red-ui-header-shade").hide();
+ $("#red-ui-editor-shade").hide();
+ $("#red-ui-palette-shade").hide();
+ $("#red-ui-sidebar-shade").hide();
+ }
+ function deployButtonSetBusy(){
+ $(".red-ui-deploy-button-content").css('opacity',0);
+ $(".red-ui-deploy-button-spinner").show();
+ $("#red-ui-header-button-deploy").addClass("disabled");
+ }
+ function deployButtonClearBusy(){
+ $(".red-ui-deploy-button-content").css('opacity',1);
+ $(".red-ui-deploy-button-spinner").hide();
+ }
+ function stopStartFlows(state) {
+ const startTime = Date.now();
+ const deployWasEnabled = !$("#red-ui-header-button-deploy").hasClass("disabled");
+ deployInflight = true;
+ deployButtonSetBusy();
+ shadeShow();
+ RED.runtime.updateState(state);
+ $.ajax({
+ url:"flows/state",
+ type: "POST",
+ data: {state: state},
+ headers: {
+ "Node-RED-Flow-Run-State-Change": state
+ }
+ }).done(function(data,textStatus,xhr) {
+ if (deployWasEnabled) {
+ $("#red-ui-header-button-deploy").removeClass("disabled");
+ }
+ RED.runtime.updateState((data && data.state) || "unknown" )
+ RED.notify('
Done
',"success");
+ }).fail(function(xhr,textStatus,err) {
+ if (deployWasEnabled) {
+ $("#red-ui-header-button-deploy").removeClass("disabled");
+ }
+ if (xhr.status === 401) {
+ RED.notify("Not authorized" ,"error");
+ } else if (xhr.responseText) {
+ RED.notify("Operation failed: " + xhr.responseText,"error");
+ } else {
+ RED.notify("Operation failed: no response","error");
+ }
+ RED.runtime.requestState()
+ }).always(function() {
+ const delta = Math.max(0,300-(Date.now()-startTime));
+ setTimeout(function() {
+ deployButtonClearBusy();
+ shadeHide()
+ deployInflight = false;
+ },delta);
+ });
+ }
+ function restart() {
+ var startTime = Date.now();
+ var deployWasEnabled = !$("#red-ui-header-button-deploy").hasClass("disabled");
+ deployInflight = true;
+ deployButtonSetBusy();
$.ajax({
url:"flows",
type: "POST",
@@ -307,15 +367,10 @@ RED.deploy = (function() {
RED.notify(RED._("deploy.deployFailed",{message:RED._("deploy.errors.noResponse")}),"error");
}
}).always(function() {
- deployInflight = false;
var delta = Math.max(0,300-(Date.now()-startTime));
setTimeout(function() {
- $(".red-ui-deploy-button-content").css('opacity',1);
- $(".red-ui-deploy-button-spinner").hide();
- $("#red-ui-header-shade").hide();
- $("#red-ui-editor-shade").hide();
- $("#red-ui-palette-shade").hide();
- $("#red-ui-sidebar-shade").hide();
+ deployButtonClearBusy();
+ deployInflight = false;
},delta);
});
}
@@ -450,21 +505,17 @@ RED.deploy = (function() {
const nns = RED.nodes.createCompleteNodeSet();
const startTime = Date.now();
- $(".red-ui-deploy-button-content").css('opacity', 0);
- $(".red-ui-deploy-button-spinner").show();
- $("#red-ui-header-button-deploy").addClass("disabled");
-
+ deployButtonSetBusy();
const data = { flows: nns };
-
- if (!force) {
+ data.runtimeState = RED.runtime.state;
+ if (data.runtimeState === RED.runtime.states.STOPPED || force) {
+ data._rev = RED.nodes.version();
+ } else {
data.rev = RED.nodes.version();
}
-
+
deployInflight = true;
- $("#red-ui-header-shade").show();
- $("#red-ui-editor-shade").show();
- $("#red-ui-palette-shade").show();
- $("#red-ui-sidebar-shade").show();
+ shadeShow();
$.ajax({
url: "flows",
type: "POST",
@@ -550,15 +601,11 @@ RED.deploy = (function() {
RED.notify(RED._("deploy.deployFailed", { message: RED._("deploy.errors.noResponse") }), "error");
}
}).always(function () {
- deployInflight = false;
const delta = Math.max(0, 300 - (Date.now() - startTime));
setTimeout(function () {
- $(".red-ui-deploy-button-content").css('opacity', 1);
- $(".red-ui-deploy-button-spinner").hide();
- $("#red-ui-header-shade").hide();
- $("#red-ui-editor-shade").hide();
- $("#red-ui-palette-shade").hide();
- $("#red-ui-sidebar-shade").hide();
+ deployInflight = false;
+ deployButtonClearBusy()
+ shadeHide()
}, delta);
});
}
diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/view.js b/packages/node_modules/@node-red/editor-client/src/js/ui/view.js
index adab87e38..59aa3fe6e 100755
--- a/packages/node_modules/@node-red/editor-client/src/js/ui/view.js
+++ b/packages/node_modules/@node-red/editor-client/src/js/ui/view.js
@@ -4792,6 +4792,9 @@ RED.view = (function() {
if (d._def.button) {
var buttonEnabled = isButtonEnabled(d);
this.__buttonGroup__.classList.toggle("red-ui-flow-node-button-disabled", !buttonEnabled);
+ if(RED.runtime && Object.hasOwn(RED.runtime,'started')) {
+ this.__buttonGroup__.classList.toggle("red-ui-flow-node-button-stopped", !RED.runtime.started);
+ }
var x = d._def.align == "right"?d.w-6:-25;
if (d._def.button.toggle && !d[d._def.button.toggle]) {
diff --git a/packages/node_modules/@node-red/editor-client/src/sass/flow.scss b/packages/node_modules/@node-red/editor-client/src/sass/flow.scss
index 2e6de1932..105d32cd6 100644
--- a/packages/node_modules/@node-red/editor-client/src/sass/flow.scss
+++ b/packages/node_modules/@node-red/editor-client/src/sass/flow.scss
@@ -176,6 +176,13 @@
cursor: default;
}
}
+ &.red-ui-flow-node-button-stopped {
+ opacity: 0.4;
+ .red-ui-flow-node-button-button {
+ cursor: default;
+ pointer-events: none;
+ }
+ }
}
.red-ui-flow-node-button-button {
cursor: pointer;
diff --git a/packages/node_modules/@node-red/runtime/lib/api/flows.js b/packages/node_modules/@node-red/runtime/lib/api/flows.js
index d28a0479a..83ef68021 100644
--- a/packages/node_modules/@node-red/runtime/lib/api/flows.js
+++ b/packages/node_modules/@node-red/runtime/lib/api/flows.js
@@ -73,6 +73,10 @@ var api = module.exports = {
if (deploymentType === 'reload') {
apiPromise = runtime.flows.loadFlows(true);
} else {
+ //ensure the runtime running/stopped state matches the deploying editor. If not, then copy the _rev number to flows.rev
+ if(flows.hasOwnProperty('_rev') && !flows.hasOwnProperty('rev') && (flows.runtimeState !== "stopped" || runtime.flows.started)) {
+ flows.rev = flows._rev
+ }
if (flows.hasOwnProperty('rev')) {
var currentVersion = runtime.flows.getFlows().rev;
if (currentVersion !== flows.rev) {
@@ -255,5 +259,83 @@ var api = module.exports = {
}
}
return sendCredentials;
- }
+ },
+ /**
+ * Gets running state of runtime flows
+ * @param {Object} opts
+ * @param {User} opts.user - the user calling the api
+ * @param {Object} opts.req - the request to log (optional)
+ * @return {{state:string, started:boolean}} - the current run state of the flows
+ * @memberof @node-red/runtime_flows
+ */
+ getState: async function(opts) {
+ runtime.log.audit({event: "flows.getState"}, opts.req);
+ const result = {
+ state: runtime.flows.started ? "started" : "stopped",
+ started: !!runtime.flows.started,
+ rev: runtime.flows.getFlows().rev
+ }
+ return result;
+ },
+ /**
+ * Sets running state of runtime flows
+ * @param {Object} opts
+ * @param {Object} opts.req - the request to log (optional)
+ * @param {User} opts.user - the user calling the api
+ * @param {string} opts.requestedState - the requested state. Valid values are "start" and "stop".
+ * @return {Promise} - the active flow configuration
+ * @memberof @node-red/runtime_flows
+ */
+ setState: async function(opts) {
+ opts = opts || {};
+ const makeError = (error, errcode, statusCode) => {
+ const message = typeof error == "object" ? error.message : error
+ const err = typeof error == "object" ? error : new Error(message||"Unexpected Error")
+ err.status = err.status || statusCode || 400;
+ err.code = err.code || errcode || "unexpected_error"
+ runtime.log.audit({
+ event: "flows.setState",
+ state: opts.requestedState || "",
+ error: errcode || "unexpected_error",
+ message: err.code
+ }, opts.req);
+ return err
+ }
+
+ const getState = () => {
+ return {
+ state: runtime.flows.started ? "started" : "stopped",
+ started: !!runtime.flows.started,
+ rev: runtime.flows.getFlows().rev,
+ }
+ }
+
+ if(runtime.settings.runtimeState ? runtime.settings.runtimeState.enabled === false : false) {
+ throw (makeError("Method Not Allowed", "not_allowed", 405))
+ }
+ switch (opts.requestedState) {
+ case "start":
+ try {
+ try {
+ runtime.settings.set('flowsRunStateRequested', opts.requestedState);
+ } catch(err) { }
+ await runtime.flows.startFlows("full")
+ return getState()
+ } catch (err) {
+ throw (makeError(err, err.code, 500))
+ }
+ case "stop":
+ try {
+ try {
+ runtime.settings.set('flowsRunStateRequested', opts.requestedState);
+ } catch(err) { }
+ await runtime.flows.stopFlows("full")
+ return getState()
+ } catch (err) {
+ throw (makeError(err, err.code, 500))
+ }
+ default:
+ throw (makeError("Cannot set runtime state. Invalid state", "invalid_run_state", 400))
+ }
+ },
}
diff --git a/packages/node_modules/@node-red/runtime/lib/api/settings.js b/packages/node_modules/@node-red/runtime/lib/api/settings.js
index f56b8ab61..e35a7861d 100644
--- a/packages/node_modules/@node-red/runtime/lib/api/settings.js
+++ b/packages/node_modules/@node-red/runtime/lib/api/settings.js
@@ -148,6 +148,18 @@ var api = module.exports = {
enabled: (runtime.settings.diagnostics && runtime.settings.diagnostics.enabled === false) ? false : true,
ui: (runtime.settings.diagnostics && runtime.settings.diagnostics.ui === false) ? false : true
}
+ if(safeSettings.diagnostics.enabled === false) {
+ safeSettings.diagnostics.ui = false; // cannot have UI without endpoint
+ }
+
+ safeSettings.runtimeState = {
+ //unless runtimeState.ui and runtimeState.enabled are explicitly false, they will default to true.
+ enabled: (runtime.settings.runtimeState && runtime.settings.runtimeState.enabled === false) ? false : true,
+ ui: (runtime.settings.runtimeState && runtime.settings.runtimeState.ui === false) ? false : true
+ }
+ if(safeSettings.runtimeState.enabled === false) {
+ safeSettings.runtimeState.ui = false; // cannot have UI without endpoint
+ }
runtime.settings.exportNodeSettings(safeSettings);
runtime.plugins.exportPluginSettings(safeSettings);
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 ae9131ec0..b707b4aaf 100644
--- a/packages/node_modules/@node-red/runtime/lib/flows/index.js
+++ b/packages/node_modules/@node-red/runtime/lib/flows/index.js
@@ -261,6 +261,7 @@ function getFlows() {
async function start(type,diff,muteLog) {
type = type||"full";
+ let reallyStarted = started
started = true;
var i;
// If there are missing types, report them, emit the necessary runtime event and return
@@ -365,24 +366,42 @@ async function start(type,diff,muteLog) {
}
}
// Having created or updated all flows, now start them.
- for (id in activeFlows) {
- if (activeFlows.hasOwnProperty(id)) {
- try {
- activeFlows[id].start(diff);
+ let startFlows = true
+ try {
+ startFlows = settings.get('flowsRunStateRequested');
+ } catch(err) {
+ }
+ startFlows = (startFlows !== "stop");
- // Create a map of node id to flow id and also a subflowInstance lookup map
- var activeNodes = activeFlows[id].getActiveNodes();
- Object.keys(activeNodes).forEach(function(nid) {
- activeNodesToFlow[nid] = id;
- });
- } catch(err) {
- console.log(err.stack);
+ if (startFlows) {
+ for (id in activeFlows) {
+ if (activeFlows.hasOwnProperty(id)) {
+ try {
+ activeFlows[id].start(diff);
+ // Create a map of node id to flow id and also a subflowInstance lookup map
+ var activeNodes = activeFlows[id].getActiveNodes();
+ Object.keys(activeNodes).forEach(function(nid) {
+ activeNodesToFlow[nid] = id;
+ });
+ } catch(err) {
+ console.log(err.stack);
+ }
}
}
+ reallyStarted = true;
+ events.emit("flows:started", {config: activeConfig, type: type, diff: diff});
+ // Deprecated event
+ events.emit("nodes-started");
+ } else {
+ started = false;
}
- events.emit("flows:started", {config: activeConfig, type: type, diff: diff});
- // Deprecated event
- events.emit("nodes-started");
+
+ const state = {
+ started: reallyStarted,
+ state: reallyStarted ? "started" : "stopped",
+ }
+ events.emit("runtime-event",{id:"flows-run-state", payload: state, retain:true});
+
if (credentialsPendingReset === true) {
credentialsPendingReset = false;
@@ -390,7 +409,7 @@ async function start(type,diff,muteLog) {
events.emit("runtime-event",{id:"runtime-state",retain:true});
}
- if (!muteLog) {
+ if (!muteLog && reallyStarted) {
if (type !== "full") {
log.info(log._("nodes.flows.started-modified-"+type));
} else {
@@ -471,6 +490,7 @@ function stop(type,diff,muteLog) {
}
}
events.emit("flows:stopped",{config: activeConfig, type: type, diff: diff});
+ events.emit("runtime-event",{id:"flows-run-state", payload: {started: false, state: "stopped"}, retain:true});
// Deprecated event
events.emit("nodes-stopped");
});
diff --git a/packages/node_modules/node-red/settings.js b/packages/node_modules/node-red/settings.js
index 2e2b7035e..72f81266d 100644
--- a/packages/node_modules/node-red/settings.js
+++ b/packages/node_modules/node-red/settings.js
@@ -242,6 +242,7 @@ module.exports = {
/*******************************************************************************
* Runtime Settings
* - lang
+ * - runtimeState
* - diagnostics
* - logging
* - contextStorage
@@ -267,7 +268,19 @@ module.exports = {
/** enable or disable diagnostics display in the node-red editor. Must be set to `false` to disable */
ui: true,
},
-
+ /** Configure runtimeState options
+ * - enabled: When `enabled` is `true` (or unset), runtime Start/Stop will
+ * be available at http://localhost:1880/flows/state
+ * - ui: When `ui` is `true` (or unset), the action `core:start-flows` and
+ * `core:stop-flows` be available to logged in users of node-red editor
+ * Also, the deploy menu (when set to default) will show a stop or start button
+ */
+ runtimeState: {
+ /** enable or disable flows/state endpoint. Must be set to `false` to disable */
+ enabled: true,
+ /** show or hide runtime stop/start options in the node-red editor. Must be set to `false` to hide */
+ ui: true,
+ },
/** Configure the logging output */
logging: {
/** Only console logging is currently supported */
diff --git a/test/unit/@node-red/editor-api/lib/admin/flows_spec.js b/test/unit/@node-red/editor-api/lib/admin/flows_spec.js
index ac295a194..ba09c9fa1 100644
--- a/test/unit/@node-red/editor-api/lib/admin/flows_spec.js
+++ b/test/unit/@node-red/editor-api/lib/admin/flows_spec.js
@@ -32,7 +32,9 @@ describe("api/admin/flows", function() {
app = express();
app.use(bodyParser.json());
app.get("/flows",flows.get);
+ app.get("/flows/state",flows.getState);
app.post("/flows",flows.post);
+ app.post("/flows/state",flows.postState);
});
it('returns flow - v1', function(done) {
@@ -208,4 +210,99 @@ describe("api/admin/flows", function() {
done();
});
});
+ it('returns flows run state', function (done) {
+ var setFlows = sinon.spy(function () { return Promise.resolve(); });
+ flows.init({
+ flows: {
+ setFlows,
+ getState: async function () {
+ return { started: true, state: "started" };
+ }
+ }
+ });
+ request(app)
+ .get('/flows/state')
+ .set('Accept', 'application/json')
+ .set('Node-RED-Deployment-Type', 'reload')
+ .expect(200)
+ .end(function (err, res) {
+ if (err) {
+ return done(err);
+ }
+ try {
+ res.body.should.have.a.property('started', true);
+ res.body.should.have.a.property('state', "started");
+ done();
+ } catch (e) {
+ return done(e);
+ }
+ });
+ });
+ it('sets flows run state - stopped', function (done) {
+ var setFlows = sinon.spy(function () { return Promise.resolve(); });
+ flows.init({
+ flows: {
+ setFlows: setFlows,
+ getState: async function () {
+ return { started: true, state: "started" };
+ },
+ setState: async function () {
+ return { started: false, state: "stopped" };
+ },
+ }
+ });
+ request(app)
+ .post('/flows/state')
+ .set('Accept', 'application/json')
+ .set('Node-RED-Flow-Run-State-Change', 'stop')
+ .expect(200)
+ .end(function (err, res) {
+ if (err) {
+ return done(err);
+ }
+ try {
+ res.body.should.have.a.property('started', false);
+ res.body.should.have.a.property('state', "stopped");
+ done();
+ } catch (e) {
+ return done(e);
+ }
+ });
+ });
+ it('sets flows run state - bad value', function (done) {
+ var setFlows = sinon.spy(function () { return Promise.resolve(); });
+ const makeError = (error, errcode, statusCode) => {
+ const message = typeof error == "object" ? error.message : error
+ const err = typeof error == "object" ? error : new Error(message||"Unexpected Error")
+ err.status = err.status || statusCode || 400;
+ err.code = err.code || errcode || "unexpected_error"
+ return err
+ }
+ flows.init({
+ flows: {
+ setFlows: setFlows,
+ getState: async function () {
+ return { started: true, state: "started" };
+ },
+ setState: async function () {
+ var err = (makeError("Cannot set runtime state. Invalid state", "invalid_run_state", 400))
+ var p = Promise.reject(err);
+ p.catch(()=>{});
+ return p;
+ },
+ }
+ });
+ request(app)
+ .post('/flows/state')
+ .set('Accept', 'application/json')
+ .set('Node-RED-Flow-Run-State-Change', 'bad-state')
+ .expect(400)
+ .end(function(err,res) {
+ if (err) {
+ return done(err);
+ }
+ res.body.should.have.property("code","invalid_run_state");
+ done();
+ });
+ });
});
diff --git a/test/unit/@node-red/runtime/lib/api/flows_spec.js b/test/unit/@node-red/runtime/lib/api/flows_spec.js
index 9062ef52f..0f560e3f8 100644
--- a/test/unit/@node-red/runtime/lib/api/flows_spec.js
+++ b/test/unit/@node-red/runtime/lib/api/flows_spec.js
@@ -427,4 +427,126 @@ describe("runtime-api/flows", function() {
});
});
+ describe("flow run state", function() {
+ var startFlows, stopFlows, runtime;
+ beforeEach(function() {
+ let flowsStarted = true;
+ let flowsState = "started";
+ startFlows = sinon.spy(function(type) {
+ if (type !== "full") {
+ var err = new Error();
+ // TODO: quirk of internal api - uses .code for .status
+ err.code = 400;
+ var p = Promise.reject(err);
+ p.catch(()=>{});
+ return p;
+ }
+ flowsStarted = true;
+ flowsState = "started";
+ return Promise.resolve();
+ });
+ stopFlows = sinon.spy(function(type) {
+ if (type !== "full") {
+ var err = new Error();
+ // TODO: quirk of internal api - uses .code for .status
+ err.code = 400;
+ var p = Promise.reject(err);
+ p.catch(()=>{});
+ return p;
+ }
+ flowsStarted = false;
+ flowsState = "stopped";
+ return Promise.resolve();
+ });
+ runtime = {
+ log: mockLog(),
+ settings: {
+ runtimeState: {
+ enabled: true,
+ ui: true,
+ },
+ },
+ flows: {
+ get started() {
+ return flowsStarted;
+ },
+ startFlows,
+ stopFlows,
+ getFlows: function() { return {rev:"currentRev",flows:[]} },
+ }
+ }
+ })
+
+ it("gets flows run state", async function() {
+ flows.init(runtime);
+ const state = await flows.getState({})
+ state.should.have.property("started", true)
+ state.should.have.property("state", "started")
+ });
+ it("permits getting flows run state when setting disabled", async function() {
+ runtime.settings.runtimeState.enabled = false;
+ flows.init(runtime);
+ const state = await flows.getState({})
+ state.should.have.property("started", true)
+ state.should.have.property("state", "started")
+ });
+ it("start flows", async function() {
+ flows.init(runtime);
+ const state = await flows.setState({requestedState:"start"})
+ state.should.have.property("started", true)
+ state.should.have.property("state", "started")
+ stopFlows.called.should.not.be.true();
+ startFlows.called.should.be.true();
+ });
+ it("stop flows", async function() {
+ flows.init(runtime);
+ const state = await flows.setState({requestedState:"stop"})
+ state.should.have.property("started", false)
+ state.should.have.property("state", "stopped")
+ stopFlows.called.should.be.true();
+ startFlows.called.should.not.be.true();
+ });
+ it("rejects starting flows when setting disabled", async function() {
+ let err;
+ runtime.settings.runtimeState.enabled = false;
+ flows.init(runtime);
+ try {
+ await flows.setState({requestedState:"start"})
+ } catch (error) {
+ err = error
+ }
+ stopFlows.called.should.not.be.true();
+ startFlows.called.should.not.be.true();
+ should(err).have.property("code", "not_allowed")
+ should(err).have.property("status", 405)
+ });
+ it("rejects stopping flows when setting disabled", async function() {
+ let err;
+ runtime.settings.runtimeState.enabled = false;
+ flows.init(runtime);
+ try {
+ await flows.setState({requestedState:"stop"})
+ } catch (error) {
+ err = error
+ }
+ stopFlows.called.should.not.be.true();
+ startFlows.called.should.not.be.true();
+ should(err).have.property("code", "not_allowed")
+ should(err).have.property("status", 405)
+ });
+ it("rejects setting invalid flows run state", async function() {
+ let err;
+ flows.init(runtime);
+ try {
+ await flows.setState({requestedState:"bad-state"})
+ } catch (error) {
+ err = error
+ }
+ stopFlows.called.should.not.be.true();
+ startFlows.called.should.not.be.true();
+ should(err).have.property("code", "invalid_run_state")
+ should(err).have.property("status", 400)
+ });
+ });
+
});
diff --git a/test/unit/@node-red/runtime/lib/flows/index_spec.js b/test/unit/@node-red/runtime/lib/flows/index_spec.js
index 737846100..bacb94b0f 100644
--- a/test/unit/@node-red/runtime/lib/flows/index_spec.js
+++ b/test/unit/@node-red/runtime/lib/flows/index_spec.js
@@ -131,7 +131,7 @@ describe('flows/index', function() {
// eventsOn.calledOnce.should.be.true();
// });
// });
-
+/*
describe('#setFlows', function() {
it('sets the full flow', function(done) {
var originalConfig = [
@@ -300,6 +300,7 @@ describe('flows/index', function() {
});
});
});
+ */
describe('#startFlows', function() {
it('starts the loaded config', function(done) {
@@ -321,6 +322,87 @@ describe('flows/index', function() {
return flows.startFlows();
});
});
+ it('emits runtime-event "flows-run-state" "started"', async function () {
+ var originalConfig = [
+ { id: "t1-1", x: 10, y: 10, z: "t1", type: "test", wires: [] },
+ { id: "t1", type: "tab" }
+ ];
+ storage.getFlows = function () {
+ return Promise.resolve({ flows: originalConfig });
+ }
+ let receivedEvent = null;
+ const handleEvent = (data) => {
+ console.log(data)
+ if(data && data.id === 'flows-run-state') {
+ receivedEvent = data;
+ }
+ }
+ events.on('runtime-event', handleEvent);
+ flows.init({ log: mockLog, settings: {}, storage: storage });
+ await flows.load()
+ await flows.startFlows()
+ events.removeListener("runtime-event", handleEvent);
+
+ //{id:"flows-run-state", payload: {started: true, state: "started"}
+ should(receivedEvent).not.be.null()
+ receivedEvent.should.have.property("id", "flows-run-state")
+ receivedEvent.should.have.property("payload", { started: true, state: "started" })
+ receivedEvent.should.have.property("retain", true)
+ });
+ it('emits runtime-event "flows-run-state" "stopped"', async function () {
+ const originalConfig = [
+ { id: "t1-1", x: 10, y: 10, z: "t1", type: "test", wires: [] },
+ { id: "t1", type: "tab" }
+ ];
+ storage.getFlows = function () {
+ return Promise.resolve({ flows: originalConfig });
+ }
+ let receivedEvent = null;
+ const handleEvent = (data) => {
+ if(data && data.id === 'flows-run-state') {
+ receivedEvent = data;
+ }
+ }
+ events.on('runtime-event', handleEvent);
+ flows.init({ log: mockLog, settings: {}, storage: storage });
+ await flows.load()
+ await flows.startFlows()
+ await flows.stopFlows()
+ events.removeListener("runtime-event", handleEvent);
+
+ //{id:"flows-run-state", payload: {started: true, state: "started"}
+ should(receivedEvent).not.be.null()
+ receivedEvent.should.have.property("id", "flows-run-state")
+ receivedEvent.should.have.property("payload", { started: false, state: "stopped" })
+ receivedEvent.should.have.property("retain", true)
+ });
+ // it('raises error when invalid flows run state requested', async function () {
+ // const originalConfig = [
+ // { id: "t1-1", x: 10, y: 10, z: "t1", type: "test", wires: [] },
+ // { id: "t1", type: "tab" }
+ // ];
+ // storage.getFlows = function () {
+ // return Promise.resolve({ flows: originalConfig });
+ // }
+ // let receivedEvent = null;
+ // const handleEvent = (data) => {
+ // if(data && data.id === 'flows-run-state') {
+ // receivedEvent = data;
+ // }
+ // }
+ // events.on('runtime-event', handleEvent);
+ // flows.init({ log: mockLog, settings: {}, storage: storage });
+ // await flows.load()
+ // await flows.startFlows()
+ // await flows.stopFlows()
+ // events.removeListener("runtime-event", handleEvent);
+
+ // //{id:"flows-run-state", payload: {started: true, state: "started"}
+ // should(receivedEvent).not.be.null()
+ // receivedEvent.should.have.property("id", "flows-run-state")
+ // receivedEvent.should.have.property("payload", { started: false, state: "stopped" })
+ // receivedEvent.should.have.property("retain", true)
+ // });
it('does not start if nodes missing', function(done) {
var originalConfig = [
{id:"t1-1",x:10,y:10,z:"t1",type:"test",wires:[]},
@@ -415,7 +497,7 @@ describe('flows/index', function() {
describe.skip('#get',function() {
});
-
+/*
describe('#eachNode', function() {
it('iterates the flow nodes', function(done) {
var originalConfig = [
@@ -582,7 +664,7 @@ describe('flows/index', function() {
];
flows.init({log:mockLog, settings:{},storage:storage});
flows.setFlows(originalConfig).then(function() {
- /*jshint immed: false */
+
try {
flows.checkTypeInUse("used-module");
done("type_in_use error not thrown");
@@ -666,4 +748,5 @@ describe('flows/index', function() {
describe('#enableFlow', function() {
it.skip("enableFlow");
})
+ */
});