diff --git a/Gruntfile.js b/Gruntfile.js index a168d7ce4..2f81da923 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..4d8679aac 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, + state: req.body.state || "", + 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..8406fa8e9 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 === true) { + 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/locales/en-US/editor.json b/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json index b97151022..87725bd14 100755 --- 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 @@ -169,6 +169,10 @@ } }, "notification": { + "state": { + "flowsStopped": "Flows stopped", + "flowsStarted": "Flows started" + }, "warning": "Warning: __message__", "warnings": { "undeployedChanges": "node has undeployed changes", 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/nodes.js b/packages/node_modules/@node-red/editor-client/src/js/nodes.js index 45804c29e..a898834b0 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 @@ -14,7 +14,7 @@ * limitations under the License. **/ -/** +/** * An Interface to nodes and utility functions for creating/adding/deleting nodes and links * @namespace RED.nodes */ 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..55446418b 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(); @@ -332,7 +336,6 @@ var RED = (function() { id: notificationId } if (notificationId === "runtime-state") { - RED.events.emit("runtime-state",msg); if (msg.error === "safe-mode") { options.buttons = [ { @@ -473,9 +476,9 @@ var RED = (function() { } else if (persistentNotifications.hasOwnProperty(notificationId)) { persistentNotifications[notificationId].close(); delete persistentNotifications[notificationId]; - if (notificationId === 'runtime-state') { - RED.events.emit("runtime-state",msg); - } + } + if (notificationId === 'runtime-state') { + RED.events.emit("runtime-state",msg); } }); RED.comms.subscribe("status/#",function(topic,msg) { @@ -747,6 +750,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..eac985467 --- /dev/null +++ b/packages/node_modules/@node-red/editor-client/src/js/runtime.js @@ -0,0 +1,36 @@ +RED.runtime = (function() { + let state = "" + let settings = { ui: false, enabled: false }; + const STOPPED = "stop" + const STARTED = "start" + const SAFE = "safe" + + return { + init: function() { + // refresh the current runtime status from server + settings = Object.assign({}, settings, RED.settings.runtimeState); + RED.events.on("runtime-state", function(msg) { + if (msg.state) { + const currentState = state + state = msg.state + $(".red-ui-flow-node-button").toggleClass("red-ui-flow-node-button-stopped", state !== STARTED) + if(settings.enabled === true && settings.ui === true) { + RED.menu.setVisible("deploymenu-item-runtime-stop", state === STARTED) + RED.menu.setVisible("deploymenu-item-runtime-start", state !== STARTED) + } + // Do not notify the user about this event if: + // - This is the very first event we've received after loading the editor (currentState = '') + // - The state matches what we already thought was the case (state === currentState) + // - The event was triggered by a deploy (msg.deploy === true) + // - The event is a safe mode event - that gets notified separately + if (currentState !== '' && state !== currentState && !msg.deploy && state !== SAFE) { + RED.notify(RED._("notification.state.flows"+(state === STOPPED?'Stopped':'Started'), msg), "success") + } + } + }); + }, + get started() { + return state === STARTED + } + } +})() 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 c5b8d1d06..2d95f894a 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 @@ -167,6 +167,9 @@ RED.menu = (function() { if (opt.disabled) { item.addClass("disabled"); } + if (opt.visible === false) { + item.addClass("hide"); + } } @@ -303,6 +306,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) { @@ -359,6 +370,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..f0d4754f4 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 === true) { + 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,10 @@ RED.deploy = (function() { RED.actions.add("core:deploy-flows",save); if (type === "default") { + if (RED.settings.runtimeState && RED.settings.runtimeState.ui === true) { + 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 +276,73 @@ 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() + $.ajax({ + url:"flows/state", + type: "POST", + data: {state: state} + }).done(function(data,textStatus,xhr) { + if (deployWasEnabled) { + $("#red-ui-header-button-deploy").removeClass("disabled") + } + }).fail(function(xhr,textStatus,err) { + if (deployWasEnabled) { + $("#red-ui-header-button-deploy").removeClass("disabled") + } + if (xhr.status === 401) { + RED.notify(RED._("notification.error", { message: RED._("user.notAuthorized") }), "error") + } else if (xhr.responseText) { + const errorDetail = { message: err ? (err + "") : "" } + try { + errorDetail.message = JSON.parse(xhr.responseText).message + } finally { + errorDetail.message = errorDetail.message || xhr.responseText + } + RED.notify(RED._("notification.error", errorDetail), "error") + } else { + RED.notify(RED._("notification.error", { message: RED._("deploy.errors.noResponse") }), "error") + } + }).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 +368,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 +506,14 @@ 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.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 +599,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 0ddc06a8c..02b8df5d8 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 @@ -4877,6 +4877,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 2d4877896..be8db6c93 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/editor-client/src/sass/header.scss b/packages/node_modules/@node-red/editor-client/src/sass/header.scss index 19c15b015..e837f0805 100644 --- a/packages/node_modules/@node-red/editor-client/src/sass/header.scss +++ b/packages/node_modules/@node-red/editor-client/src/sass/header.scss @@ -219,7 +219,7 @@ span.red-ui-menu-sublabel { color: var(--red-ui-header-menu-sublabel-color); font-size: 13px; - display: inline-block; + display: block; text-indent: 0px; } } 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..b91635201 100644 --- a/packages/node_modules/@node-red/runtime/lib/api/flows.js +++ b/packages/node_modules/@node-red/runtime/lib/api/flows.js @@ -255,5 +255,82 @@ 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.state() + } + 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.state - 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.state || "", + error: errcode || "unexpected_error", + message: err.code + }, opts.req); + return err + } + + const getState = () => { + return { + state: runtime.flows.state() + } + } + + if(!runtime.settings.runtimeState || runtime.settings.runtimeState.enabled !== true) { + throw (makeError("Method Not Allowed", "not_allowed", 405)) + } + switch (opts.state) { + case "start": + try { + try { + runtime.settings.set('runtimeFlowState', opts.state); + } catch(err) {} + if (runtime.settings.safeMode) { + delete runtime.settings.safeMode + } + await runtime.flows.startFlows("full") + return getState() + } catch (err) { + throw (makeError(err, err.code, 500)) + } + case "stop": + try { + try { + runtime.settings.set('runtimeFlowState', opts.state); + } catch(err) {} + await runtime.flows.stopFlows("full") + return getState() + } catch (err) { + throw (makeError(err, err.code, 500)) + } + default: + throw (makeError(`Cannot change flows runtime state to '${opts.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 9bf640227..6c13596ce 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 true, they will default to false. + enabled: !!runtime.settings.runtimeState && runtime.settings.runtimeState.enabled === true, + ui: !!runtime.settings.runtimeState && runtime.settings.runtimeState.ui === true + } + if(safeSettings.runtimeState.enabled !== true) { + 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..1b5476a3f 100644 --- a/packages/node_modules/@node-red/runtime/lib/flows/index.js +++ b/packages/node_modules/@node-red/runtime/lib/flows/index.js @@ -36,6 +36,8 @@ var activeFlowConfig = null; var activeFlows = {}; var started = false; +var state = 'stop' + var credentialsPendingReset = false; var activeNodesToFlow = {}; @@ -50,6 +52,7 @@ function init(runtime) { storage = runtime.storage; log = runtime.log; started = false; + state = 'stop'; if (!typeEventRegistered) { events.on('type-registered',function(type) { if (activeFlowConfig && activeFlowConfig.missingTypes.length > 0) { @@ -214,19 +217,26 @@ function setFlows(_config,_credentials,type,muteLog,forceStart,user) { // Flows are running (or should be) // Stop the active flows (according to deploy type and the diff) - return stop(type,diff,muteLog).then(() => { + return stop(type,diff,muteLog,true).then(() => { // Once stopped, allow context to remove anything no longer needed return context.clean(activeFlowConfig) }).then(() => { + if (!isLoad) { + log.info(log._("nodes.flows.updated-flows")); + } // Start the active flows - start(type,diff,muteLog).then(() => { + start(type,diff,muteLog,true).then(() => { events.emit("runtime-event",{id:"runtime-deploy",payload:{revision:flowRevision},retain: true}); }); // Return the new revision asynchronously to the actual start return flowRevision; }).catch(function(err) { }) } else { + if (!isLoad) { + log.info(log._("nodes.flows.updated-flows")); + } events.emit("runtime-event",{id:"runtime-deploy",payload:{revision:flowRevision},retain: true}); + return flowRevision; } }); } @@ -259,9 +269,10 @@ function getFlows() { return activeConfig; } -async function start(type,diff,muteLog) { - type = type||"full"; +async function start(type,diff,muteLog,isDeploy) { + type = type || "full"; started = true; + state = 'start' var i; // If there are missing types, report them, emit the necessary runtime event and return if (activeFlowConfig.missingTypes.length > 0) { @@ -283,7 +294,7 @@ async function start(type,diff,muteLog) { log.info(log._("nodes.flows.missing-type-install-2")); log.info(" "+settings.userDir); } - events.emit("runtime-event",{id:"runtime-state",payload:{error:"missing-types", type:"warning",text:"notification.warnings.missing-types",types:activeFlowConfig.missingTypes},retain:true}); + events.emit("runtime-event",{id:"runtime-state",payload:{state: 'stop', error:"missing-types", type:"warning",text:"notification.warnings.missing-types",types:activeFlowConfig.missingTypes},retain:true}); return; } @@ -297,7 +308,7 @@ async function start(type,diff,muteLog) { missingModules.push({module:err[i].module.module, error: err[i].error.code || err[i].error.toString()}) log.info(` - ${err[i].module.spec} [${err[i].error.code || "unknown_error"}]`); } - events.emit("runtime-event",{id:"runtime-state",payload:{error:"missing-modules", type:"warning",text:"notification.warnings.missing-modules",modules:missingModules},retain:true}); + events.emit("runtime-event",{id:"runtime-state",payload:{state: 'stop', error:"missing-modules", type:"warning",text:"notification.warnings.missing-modules",modules:missingModules},retain:true}); return; } @@ -306,10 +317,23 @@ async function start(type,diff,muteLog) { log.info("*****************************************************************") log.info(log._("nodes.flows.safe-mode")); log.info("*****************************************************************") - events.emit("runtime-event",{id:"runtime-state",payload:{error:"safe-mode", type:"warning",text:"notification.warnings.safe-mode"},retain:true}); + state = 'safe' + events.emit("runtime-event",{id:"runtime-state",payload:{state: 'safe', error:"safe-mode", type:"warning",text:"notification.warnings.safe-mode"},retain:true}); return; } + let runtimeState + try { + runtimeState = settings.get('runtimeFlowState') || 'start' + } catch (err) {} + if (runtimeState === 'stop') { + log.info(log._("nodes.flows.stopped-flows")); + events.emit("runtime-event",{id:"runtime-state",payload:{ state: 'stop', deploy:isDeploy },retain:true}); + state = 'stop' + started = false + return + } + if (!muteLog) { if (type !== "full") { log.info(log._("nodes.flows.starting-modified-"+type)); @@ -364,12 +388,10 @@ 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); - // 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) { @@ -387,7 +409,7 @@ async function start(type,diff,muteLog) { if (credentialsPendingReset === true) { credentialsPendingReset = false; } else { - events.emit("runtime-event",{id:"runtime-state",retain:true}); + events.emit("runtime-event",{id:"runtime-state", payload:{ state: 'start', deploy:isDeploy}, retain:true}); } if (!muteLog) { @@ -400,7 +422,7 @@ async function start(type,diff,muteLog) { return; } -function stop(type,diff,muteLog) { +function stop(type,diff,muteLog,isDeploy) { if (!started) { return Promise.resolve(); } @@ -420,6 +442,7 @@ function stop(type,diff,muteLog) { } } started = false; + state = 'stop' var promises = []; var stopList; var removedList = diff.removed; @@ -471,6 +494,8 @@ function stop(type,diff,muteLog) { } } events.emit("flows:stopped",{config: activeConfig, type: type, diff: diff}); + + events.emit("runtime-event",{ id:"runtime-state", payload:{ state: 'stop', deploy:isDeploy }, retain:true }); // Deprecated event events.emit("nodes-stopped"); }); @@ -790,7 +815,7 @@ module.exports = { stopFlows: stop, get started() { return started }, - + state: () => { return state }, // handleError: handleError, // handleStatus: handleStatus, diff --git a/packages/node_modules/@node-red/runtime/lib/index.js b/packages/node_modules/@node-red/runtime/lib/index.js index 8e1d2b487..88b3b8293 100644 --- a/packages/node_modules/@node-red/runtime/lib/index.js +++ b/packages/node_modules/@node-red/runtime/lib/index.js @@ -215,7 +215,7 @@ function start() { } } return redNodes.loadContextsPlugin().then(function () { - redNodes.loadFlows().then(redNodes.startFlows).catch(function(err) {}); + redNodes.loadFlows().then(() => { redNodes.startFlows() }).catch(function(err) {}); started = true; }); }); @@ -399,12 +399,12 @@ module.exports = { * @memberof @node-red/runtime */ version: externalAPI.version, - + /** * @memberof @node-red/diagnostics */ diagnostics:externalAPI.diagnostics, - + storage: storage, events: events, hooks: hooks, diff --git a/packages/node_modules/@node-red/runtime/locales/en-US/runtime.json b/packages/node_modules/@node-red/runtime/locales/en-US/runtime.json index 3b46d0c9f..ecd010abb 100644 --- a/packages/node_modules/@node-red/runtime/locales/en-US/runtime.json +++ b/packages/node_modules/@node-red/runtime/locales/en-US/runtime.json @@ -122,6 +122,7 @@ "stopped-flows": "Stopped flows", "stopped": "Stopped", "stopping-error": "Error stopping node: __message__", + "updated-flows": "Updated flows", "added-flow": "Adding flow: __label__", "updated-flow": "Updated flow: __label__", "removed-flow": "Removed flow: __label__", diff --git a/packages/node_modules/node-red/settings.js b/packages/node_modules/node-red/settings.js index 99fa6437f..ec247a672 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 @@ -260,14 +261,26 @@ module.exports = { * be available at http://localhost:1880/diagnostics * - ui: When `ui` is `true` (or unset), the action `show-system-info` will * be available to logged in users of node-red editor - */ + */ diagnostics: { /** enable or disable diagnostics endpoint. Must be set to `false` to disable */ enabled: true, /** 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` flows runtime can be Started/Stoped + * by POSTing to available at http://localhost:1880/flows/state + * - ui: When `ui` is `true`, the action `core:start-flows` and + * `core:stop-flows` will 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: false, + /** show or hide runtime stop/start options in the node-red editor. Must be set to `false` to hide */ + ui: false, + }, /** 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..9ec6a3bc9 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') + .send({state:'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') + .send({state:'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..deed6a9b6 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,123 @@ describe("runtime-api/flows", function() { }); }); + describe("flow run state", function() { + var startFlows, stopFlows, runtime; + beforeEach(function() { + let flowsStarted = true; + let flowsState = "start"; + 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 = "start"; + 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 = "stop"; + 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:[]} }, + state: function() { return flowsState} + } + } + }) + + it("gets flows run state", async function() { + flows.init(runtime); + const state = await flows.getState({}) + state.should.have.property("state", "start") + }); + 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("state", "start") + }); + it("start flows", async function() { + flows.init(runtime); + const state = await flows.setState({state:"start"}) + state.should.have.property("state", "start") + stopFlows.called.should.not.be.true(); + startFlows.called.should.be.true(); + }); + it("stop flows", async function() { + flows.init(runtime); + const state = await flows.setState({state:"stop"}) + state.should.have.property("state", "stop") + 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({state:"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({state:"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({state:"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..1a0f2a73c 100644 --- a/test/unit/@node-red/runtime/lib/flows/index_spec.js +++ b/test/unit/@node-red/runtime/lib/flows/index_spec.js @@ -396,12 +396,13 @@ describe('flows/index', function() { try { flowCreate.called.should.be.false(); receivedEvent.should.have.property('id','runtime-state'); - receivedEvent.should.have.property('payload', - { error: 'missing-modules', - type: 'warning', - text: 'notification.warnings.missing-modules', - modules: [] } - ); + receivedEvent.should.have.property('payload', { + state: 'stop', + error: 'missing-modules', + type: 'warning', + text: 'notification.warnings.missing-modules', + modules: [] + }); done(); }catch(err) {