From e27f5d0460e180fa60000e86db141a75e845a985 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Sat, 21 Jan 2017 23:46:44 +0000 Subject: [PATCH] Add node module update api and expose in palette editor --- editor/js/main.js | 4 +- editor/js/nodes.js | 7 ++ editor/js/ui/palette-editor.js | 92 +++++++++++++++---- red/api/locales/en-US/editor.json | 17 +++- red/api/nodes.js | 26 ++++-- red/runtime/locales/en-US/runtime.json | 6 +- red/runtime/nodes/registry/index.js | 2 +- red/runtime/nodes/registry/installer.js | 52 ++++++++--- red/runtime/nodes/registry/registry.js | 25 ++++- .../runtime/nodes/registry/installer_spec.js | 29 ++++++ 10 files changed, 210 insertions(+), 50 deletions(-) diff --git a/editor/js/main.js b/editor/js/main.js index 609b9c795..a8af6f82c 100644 --- a/editor/js/main.js +++ b/editor/js/main.js @@ -24,7 +24,6 @@ url: 'nodes', success: function(data) { RED.nodes.setNodeList(data); - var nsCount = 0; for (var i=0;i
  • ")+"
  • "; RED.notify(RED._("palette.event.nodeDisabled", {count:msg.types.length})+typeList,"success"); } + } else if (topic == "node/upgraded") { + RED.notify(RED._("palette.event.nodeUpgraded", {module:msg.module,version:msg.version}),"success"); + RED.nodes.registry.setModulePendingUpdated(msg.module,msg.version); } // Refresh flow library to ensure any examples are updated RED.library.loadFlowLibrary(); diff --git a/editor/js/nodes.js b/editor/js/nodes.js index 79a2ee413..aa2e93365 100644 --- a/editor/js/nodes.js +++ b/editor/js/nodes.js @@ -42,6 +42,10 @@ RED.nodes = (function() { var nodeDefinitions = {}; var exports = { + setModulePendingUpdated: function(module,version) { + moduleList[module].pending_version = version; + RED.events.emit("registry:module-updated",{module:module,version:version}); + }, getModule: function(module) { return moduleList[module]; }, @@ -78,6 +82,9 @@ RED.nodes = (function() { local:ns.local, sets:{} }; + if (ns.pending_version) { + moduleList[ns.module].pending_version = ns.pending_version; + } moduleList[ns.module].sets[ns.name] = ns; RED.events.emit("registry:node-set-added",ns); }, diff --git a/editor/js/ui/palette-editor.js b/editor/js/ui/palette-editor.js index 556a3854d..51f272fe3 100644 --- a/editor/js/ui/palette-editor.js +++ b/editor/js/ui/palette-editor.js @@ -31,6 +31,17 @@ RED.palette.editor = (function() { var eventTimers = {}; var activeFilter = ""; + function semVerCompare(A,B) { + var aParts = A.split(".").map(function(m) { return parseInt(m);}); + var bParts = B.split(".").map(function(m) { return parseInt(m);}); + for (var i=0;i<3;i++) { + var j = aParts[i]-bParts[i]; + if (j<0) { return -1 } + if (j>0) { return 1 } + } + return 0; + } + function delayCallback(start,callback) { var delta = Date.now() - start; if (delta < 300) { @@ -64,14 +75,21 @@ RED.palette.editor = (function() { }); }) } - function installNodeModule(id,shade,callback) { + function installNodeModule(id,version,shade,callback) { + var requestBody = { + module: id + }; + if (callback === undefined) { + callback = shade; + shade = version; + } else { + requestBody.version = version; + } shade.show(); $.ajax({ url:"nodes", type: "POST", - data: JSON.stringify({ - module: id - }), + data: JSON.stringify(requestBody), contentType: "application/json; charset=utf-8" }).done(function(data,textStatus,xhr) { shade.hide(); @@ -266,19 +284,19 @@ RED.palette.editor = (function() { nodeEntry.container.toggleClass("disabled",(activeTypeCount === 0)); } } - - nodeEntry.updateButton.hide(); - // if (loadedIndex.hasOwnProperty(module)) { - // if (moduleInfo.version !== loadedIndex[module].version) { - // nodeEntry.updateButton.show(); - // nodeEntry.updateButton.html(RED._('palette.editor.update',{version:loadedIndex[module].version})); - // } else { - // nodeEntry.updateButton.hide(); - // } - // - // } else { - // nodeEntry.updateButton.hide(); - // } + if (moduleInfo.pending_version) { + nodeEntry.versionSpan.html(moduleInfo.version+' '+moduleInfo.pending_version).appendTo(nodeEntry.metaRow) + nodeEntry.updateButton.html(RED._('palette.editor.updated')).addClass('disabled').show(); + } else if (loadedIndex.hasOwnProperty(module)) { + if (semVerCompare(loadedIndex[module].version,moduleInfo.version) === 1) { + nodeEntry.updateButton.show(); + nodeEntry.updateButton.html(RED._('palette.editor.update',{version:loadedIndex[module].version})); + } else { + nodeEntry.updateButton.hide(); + } + } else { + nodeEntry.updateButton.hide(); + } } } @@ -515,7 +533,7 @@ RED.palette.editor = (function() { var titleRow = $('
    ').appendTo(headerRow); $('').html(entry.name).appendTo(titleRow); var metaRow = $('
    ').appendTo(headerRow); - $('').html(entry.version).appendTo(metaRow); + var versionSpan = $('').html(entry.version).appendTo(metaRow); var buttonRow = $('
    ',{class:"palette-module-meta"}).appendTo(headerRow); var setButton = $(' ').appendTo(buttonRow); var setCount = $('').appendTo(setButton); @@ -524,6 +542,19 @@ RED.palette.editor = (function() { var updateButton = $('').html(RED._('palette.editor.update')).appendTo(buttonGroup); updateButton.click(function(evt) { evt.preventDefault(); + if ($(this).hasClass('disabled')) { + return; + } + $("#palette-module-install-confirm").data('module',entry.name); + $("#palette-module-install-confirm").data('version',loadedIndex[entry.name].version); + $("#palette-module-install-confirm").data('shade',shade); + $("#palette-module-install-confirm-body").html(RED._("palette.editor.confirm.update.body")); + $(".palette-module-install-confirm-button-install").hide(); + $(".palette-module-install-confirm-button-remove").hide(); + $(".palette-module-install-confirm-button-update").show(); + $("#palette-module-install-confirm") + .dialog('option', 'title', RED._("palette.editor.confirm.update.title")) + .dialog('open'); }) @@ -536,6 +567,7 @@ RED.palette.editor = (function() { $("#palette-module-install-confirm-body").html(RED._("palette.editor.confirm.remove.body")); $(".palette-module-install-confirm-button-install").hide(); $(".palette-module-install-confirm-button-remove").show(); + $(".palette-module-install-confirm-button-update").hide(); $("#palette-module-install-confirm") .dialog('option', 'title', RED._("palette.editor.confirm.remove.title")) .dialog('open'); @@ -555,6 +587,7 @@ RED.palette.editor = (function() { setCount: setCount, container: container, shade: shade, + versionSpan: versionSpan, sets: {} } setButton.click(function(evt) { @@ -732,6 +765,7 @@ RED.palette.editor = (function() { $("#palette-module-install-confirm-body").html(RED._("palette.editor.confirm.install.body")); $(".palette-module-install-confirm-button-install").show(); $(".palette-module-install-confirm-button-remove").hide(); + $(".palette-module-install-confirm-button-update").hide(); $("#palette-module-install-confirm") .dialog('option', 'title', RED._("palette.editor.confirm.install.title")) .dialog('open'); @@ -807,12 +841,34 @@ RED.palette.editor = (function() { } }) + $( this ).dialog( "close" ); + } + }, + { + text: RED._("palette.editor.confirm.button.update"), + class: "primary palette-module-install-confirm-button-update", + click: function() { + var id = $(this).data('module'); + var version = $(this).data('version'); + var shade = $(this).data('shade'); + shade.show(); + installNodeModule(id,version,shade,function(xhr) { + if (xhr) { + if (xhr.responseJSON) { + RED.notify(RED._('palette.editor.errors.updateFailed',{module: id,message:xhr.responseJSON.message})); + } + } + }); $( this ).dialog( "close" ); } } ] }) + + RED.events.on('registry:module-updated', function(ns) { + refreshNodeModule(ns.module); + }); RED.events.on('registry:node-set-enabled', function(ns) { refreshNodeModule(ns.module); }); diff --git a/red/api/locales/en-US/editor.json b/red/api/locales/en-US/editor.json index 153bfb9b7..255c686e0 100644 --- a/red/api/locales/en-US/editor.json +++ b/red/api/locales/en-US/editor.json @@ -68,7 +68,8 @@ "warnings": { "undeployedChanges": "node has undeployed changes", "nodeActionDisabled": "node actions disabled within subflow", - "missing-types": "Flows stopped due to missing node types. Check logs for details." + "missing-types": "Flows stopped due to missing node types. Check logs for details.", + "restartRequired": "Node-RED must be restarted to enable upgraded modules" }, "error": "Error: __message__", @@ -250,7 +251,8 @@ "nodeEnabled": "Node enabled:", "nodeEnabled_plural": "Nodes enabled:", "nodeDisabled": "Node disabled:", - "nodeDisabled_plural": "Nodes disabled:" + "nodeDisabled_plural": "Nodes disabled:", + "nodeUpgraded": "Node module __module__ upgraded to version __version__" }, "editor": { "title": "Manage palette", @@ -283,6 +285,7 @@ "disable": "disable", "remove": "remove", "update": "update to __version__", + "updated": "updated", "install": "install", "installed": "installed", "loading": "Loading catalogues...", @@ -295,7 +298,8 @@ "errors": { "catalogLoadFailed": "Failed to load node catalogue.
    Check the browser console for more information", "installFailed": "Failed to install: __module__
    __message__
    Check the log for more information", - "removeFailed": "Failed to remove: __module__
    __message__
    Check the log for more information" + "removeFailed": "Failed to remove: __module__
    __message__
    Check the log for more information", + "updateFailed": "Failed to update: __module__
    __message__
    Check the log for more information" }, "confirm": { "install": { @@ -306,10 +310,15 @@ "body":"Removing the node will uninstall it from Node-RED. The node may continue to use resources until Node-RED is restarted.", "title": "Remove nodes" }, + "update": { + "body":"Updating the node will require a restart of Node-RED to complete the update. This must be done manually.", + "title": "Update nodes" + }, "button": { "review": "Open node information", "install": "Install", - "remove": "Remove" + "remove": "Remove", + "update": "Update" } } diff --git a/red/api/nodes.js b/red/api/nodes.js index 8f733bc2c..106e845ab 100644 --- a/red/api/nodes.js +++ b/red/api/nodes.js @@ -48,34 +48,42 @@ module.exports = { } var node = req.body; var promise; + var isUpgrade = false; if (node.module) { var module = redNodes.getModuleInfo(node.module); if (module) { - log.audit({event: "nodes.install",module:node.module,error:"module_already_loaded"},req); - res.status(400).json({error:"module_already_loaded", message:"Module already loaded"}); - return; + if (!node.version || module.version === node.version) { + log.audit({event: "nodes.install",module:node.module, version:node.version, error:"module_already_loaded"},req); + res.status(400).json({error:"module_already_loaded", message:"Module already loaded"}); + return; + } + isUpgrade = true; } - promise = redNodes.installModule(node.module); + promise = redNodes.installModule(node.module,node.version); } else { log.audit({event: "nodes.install",module:node.module,error:"invalid_request"},req); res.status(400).json({error:"invalid_request", message:"Invalid request"}); return; } promise.then(function(info) { - comms.publish("node/added",info.nodes,false); + if (isUpgrade) { + comms.publish("node/upgraded",{module:node.module,version:node.version},false); + } else { + comms.publish("node/added",info.nodes,false); + } if (node.module) { - log.audit({event: "nodes.install",module:node.module},req); + log.audit({event: "nodes.install",module:node.module,version:node.version},req); res.json(info); } }).otherwise(function(err) { if (err.code === 404) { - log.audit({event: "nodes.install",module:node.module,error:"not_found"},req); + log.audit({event: "nodes.install",module:node.module,version:node.version,error:"not_found"},req); res.status(404).end(); } else if (err.code) { - log.audit({event: "nodes.install",module:node.module,error:err.code},req); + log.audit({event: "nodes.install",module:node.module,version:node.version,error:err.code},req); res.status(400).json({error:err.code, message:err.message}); } else { - log.audit({event: "nodes.install",module:node.module,error:err.code||"unexpected_error",message:err.toString()},req); + log.audit({event: "nodes.install",module:node.module,version:node.version,error:err.code||"unexpected_error",message:err.toString()},req); res.status(400).json({error:err.code||"unexpected_error", message:err.toString()}); } }); diff --git a/red/runtime/locales/en-US/runtime.json b/red/runtime/locales/en-US/runtime.json index 703a0b513..553cf5536 100644 --- a/red/runtime/locales/en-US/runtime.json +++ b/red/runtime/locales/en-US/runtime.json @@ -24,12 +24,14 @@ "removed-types": "Removed node types:", "install": { "invalid": "Invalid module name", - "installing": "Installing module: __name__", + "installing": "Installing module: __name__, version: __version__", "installed": "Installed module: __name__", "install-failed": "Install failed", "install-failed-long": "Installation of module __name__ failed:", "install-failed-not-found": "$t(install-failed-long) module not found", - + "upgrading": "Upgrading module: __name__ to version: __version__", + "upgraded": "Upgraded module: __name__. Restart Node-RED to use the new version", + "upgrade-failed-not-found": "$t(server.install.install-failed-long) version not found", "uninstalling": "Uninstalling module: __name__", "uninstall-failed": "Uninstall failed", "uninstall-failed-long": "Uninstall of module __name__ failed:", diff --git a/red/runtime/nodes/registry/index.js b/red/runtime/nodes/registry/index.js index 2d07a8b22..46c9dc345 100644 --- a/red/runtime/nodes/registry/index.js +++ b/red/runtime/nodes/registry/index.js @@ -76,7 +76,7 @@ module.exports = { addModule: addModule, removeModule: registry.removeModule, - + installModule: installer.installModule, uninstallModule: installer.uninstallModule, diff --git a/red/runtime/nodes/registry/installer.js b/red/runtime/nodes/registry/installer.js index f2c9024d9..ca5ebdc70 100644 --- a/red/runtime/nodes/registry/installer.js +++ b/red/runtime/nodes/registry/installer.js @@ -59,33 +59,44 @@ function checkModulePath(folder) { return moduleName; } -function checkExistingModule(module) { - if (registry.getModuleInfo(module)) { - // TODO: nls - var err = new Error("Module already loaded"); - err.code = "module_already_loaded"; - throw err; +function checkExistingModule(module,version) { + var info = registry.getModuleInfo(module); + if (info) { + if (!version || info.version === version) { + var err = new Error("Module already loaded"); + err.code = "module_already_loaded"; + throw err; + } + return true; } + return false; } -function installModule(module) { +function installModule(module,version) { //TODO: ensure module is 'safe' return when.promise(function(resolve,reject) { var installName = module; - + var isUpgrade = false; try { if (moduleRe.test(module)) { // Simple module name - assume it can be npm installed + if (version) { + installName += "@"+version; + } } else if (slashRe.test(module)) { // A path - check if there's a valid package.json installName = module; module = checkModulePath(module); } - checkExistingModule(module); + isUpgrade = checkExistingModule(module,version); } catch(err) { return reject(err); } - log.info(log._("server.install.installing",{name: module})); + if (!isUpgrade) { + log.info(log._("server.install.installing",{name: module,version: version||"latest"})); + } else { + log.info(log._("server.install.upgrading",{name: module,version: version||"latest"})); + } var installDir = settings.userDir || process.env.NODE_RED_HOME || "."; var child = child_process.execFile(npmCommand,['install','--production',installName], @@ -94,10 +105,17 @@ function installModule(module) { }, function(err, stdin, stdout) { if (err) { - var lookFor404 = new RegExp(" 404 .*"+installName+"$","m"); + var e; + var lookFor404 = new RegExp(" 404 .*"+module+"$","m"); + var lookForVersionNotFound = new RegExp("version not found: "+module+"@"+version,"m"); if (lookFor404.test(stdout)) { log.warn(log._("server.install.install-failed-not-found",{name:module})); - var e = new Error("Module not found"); + e = new Error("Module not found"); + e.code = 404; + reject(e); + } else if (isUpgrade && lookForVersionNotFound.test(stdout)) { + log.warn(log._("server.install.upgrade-failed-not-found",{name:module})); + e = new Error("Module not found"); e.code = 404; reject(e); } else { @@ -108,8 +126,14 @@ function installModule(module) { reject(new Error(log._("server.install.install-failed"))); } } else { - log.info(log._("server.install.installed",{name:module})); - resolve(require("./index").addModule(module).then(reportAddedModules)); + if (!isUpgrade) { + log.info(log._("server.install.installed",{name:module})); + resolve(require("./index").addModule(module).then(reportAddedModules)); + } else { + log.info(log._("server.install.upgraded",{name:module, version:version})); + events.emit("runtime-event",{id:"restart-required",type:"warning",text:"notification.warnings.restartRequired"}); + resolve(require("./registry").setModulePendingUpdated(module,version)); + } } } ); diff --git a/red/runtime/nodes/registry/registry.js b/red/runtime/nodes/registry/registry.js index 0f7b22dd3..ba39f9e67 100644 --- a/red/runtime/nodes/registry/registry.js +++ b/red/runtime/nodes/registry/registry.js @@ -82,7 +82,8 @@ function getNode(id) { function saveNodeList() { var moduleList = {}; - + var hadPending = false; + var hasPending = false; for (var module in moduleConfigs) { /* istanbul ignore else */ if (moduleConfigs.hasOwnProperty(module)) { @@ -94,6 +95,15 @@ function saveNodeList() { local: moduleConfigs[module].local||false, nodes: {} }; + if (moduleConfigs[module].hasOwnProperty('pending_version')) { + hadPending = true; + if (moduleConfigs[module].pending_version !== moduleConfigs[module].version) { + moduleList[module].pending_version = moduleConfigs[module].pending_version; + hasPending = true; + } else { + delete moduleConfigs[module].pending_version; + } + } } var nodes = moduleConfigs[module].nodes; for(var node in nodes) { @@ -111,6 +121,9 @@ function saveNodeList() { } } } + if (hadPending && !hasPending) { + events.emit("runtime-event",{id:"restart-required"}); + } if (settings.available()) { return settings.set("nodes",moduleList); } else { @@ -280,6 +293,9 @@ function getNodeList(filter) { if (nodes.hasOwnProperty(node)) { var nodeInfo = filterNodeInfo(nodes[node]); nodeInfo.version = moduleConfigs[module].version; + if (moduleConfigs[module].pending_version) { + nodeInfo.pending_version = moduleConfigs[module].pending_version; + } if (!filter || filter(nodes[node])) { list.push(nodeInfo); } @@ -539,6 +555,12 @@ function cleanModuleList() { saveNodeList(); } } +function setModulePendingUpdated(module,version) { + moduleConfigs[module].pending_version = version; + return saveNodeList().then(function() { + return getModuleInfo(module); + }); +} var registry = module.exports = { init: init, @@ -552,6 +574,7 @@ var registry = module.exports = { enableNodeSet: enableNodeSet, disableNodeSet: disableNodeSet, + setModulePendingUpdated: setModulePendingUpdated, removeModule: removeModule, getNodeInfo: getNodeInfo, diff --git a/test/red/runtime/nodes/registry/installer_spec.js b/test/red/runtime/nodes/registry/installer_spec.js index 75d4ed518..21a59e1d2 100644 --- a/test/red/runtime/nodes/registry/installer_spec.js +++ b/test/red/runtime/nodes/registry/installer_spec.js @@ -46,6 +46,9 @@ describe('nodes/registry/installer', function() { if (registry.getModuleInfo.restore) { registry.getModuleInfo.restore(); } + if (typeRegistry.getModuleInfo.restore) { + typeRegistry.getModuleInfo.restore(); + } if (require('fs').statSync.restore) { require('fs').statSync.restore(); @@ -64,6 +67,32 @@ describe('nodes/registry/installer', function() { done(); }); }); + it("rejects when npm does not find specified version", function(done) { + sinon.stub(child_process,"execFile",function(cmd,args,opt,cb) { + cb(new Error(),""," version not found: this_wont_exist@0.1.2"); + }); + sinon.stub(typeRegistry,"getModuleInfo", function() { + return { + version: "0.1.1" + } + }); + + installer.installModule("this_wont_exist","0.1.2").otherwise(function(err) { + err.code.should.be.eql(404); + done(); + }); + }); + it("rejects when update requested to existing version", function(done) { + sinon.stub(typeRegistry,"getModuleInfo", function() { + return { + version: "0.1.1" + } + }); + installer.installModule("this_wont_exist","0.1.1").otherwise(function(err) { + err.code.should.be.eql('module_already_loaded'); + done(); + }); + }); it("rejects with generic error", function(done) { sinon.stub(child_process,"execFile",function(cmd,args,opt,cb) { cb(new Error("test_error"),"","");