diff --git a/forge/db/models/Application.js b/forge/db/models/Application.js index cf5c0fc559..d6e757ef86 100644 --- a/forge/db/models/Application.js +++ b/forge/db/models/Application.js @@ -185,6 +185,135 @@ module.exports = { return await M.Project.count({ where: { ApplicationId: this.id } }) + }, + getChildren: async function ({ includeDependencies = false } = {}) { + const application = this + const children = new Map() + const instances = await application.getInstances(includeDependencies ? { include: [M.ProjectStack] } : undefined) + const devices = await application.getDevices() + for (const instance of instances) { + children.set(instance, { model: instance, type: 'instance' }) + const instanceDevices = await app.db.models.Device.getAll(undefined, { ProjectId: instance.id }) + if (instanceDevices?.devices?.length) { + for (const device of instanceDevices.devices) { + devices.push(device) + children.set(device, { model: device, type: 'device', ownerType: 'instance', ownerId: instance.id }) + } + } + } + for (const device of devices) { + if (children.has(device)) { + continue + } + children.set(device, { model: device, type: 'device', ownerType: 'application', ownerId: application.id }) + } + + if (includeDependencies) { + const storageController = app.db.controllers.StorageSettings + for (const instance of instances) { + const child = children.get(instance) + const deps = {} + deps['node-red'] = { + semver: instance.ProjectStack?.properties?.nodered, + installed: instance.ProjectStack?.properties?.nodered + } + if (instance.ProjectStack?.replacedBy) { + const replacementStack = await app.db.models.ProjectStack.byId(instance.ProjectStack.replacedBy) + deps['node-red'].semver = replacementStack?.properties?.nodered + } + const settings = await instance.getSetting(KEY_SETTINGS) + if (Array.isArray(settings?.palette?.modules)) { + settings.palette.modules.forEach(m => { + deps[m.name] = { + semver: m.version + } + }) + } + + const projectModules = await storageController.getProjectModules(child.model) || [] + projectModules.forEach(m => { + deps[m.name] = deps[m.name] || {} + deps[m.name].installed = m.version + if (!deps[m.name].semver) { + deps[m.name].semver = m.version + } + }) + child.dependencies = deps + } + // a helper function to get the semver Node-RED version for a device. + // It takes into account the agent version, any editor settings, and the active snapshot + // This is a workaround due to having no direct access to the package.json of the device + const getDeviceNodeRedVersion = async (dev, snapshotModules) => { + const ssNodeRed = snapshotModules?.find(m => m.name === 'node-red') + if (ssNodeRed) { + return ssNodeRed.version + } + const editor = await dev.getSetting('editor') + if (editor?.nodeRedVersion) { + return editor.nodeRedVersion + } + return dev.getDefaultNodeRedVersion() + } + for (const device of devices) { + const child = children.get(device) + const deps = {} + if (device.ownerType === 'instance') { + // use the instance's dependencies as a starting point + const instance = instances.find(i => i.id === device.ProjectId) + const parent = children.get(instance) + Object.assign(deps, parent.dependencies) + } + const targetSnapshot = device.targetSnapshotId ? await device.getTargetSnapshot() : null + const activeSnapshot = device.activeSnapshotId ? await device.getActiveSnapshot() : null + const targetModulesSemver = Object.entries(targetSnapshot?.settings?.settings?.palette?.modules || {}).map(([name, version]) => ({ name, version })) + const activeModulesSemver = Object.entries(activeSnapshot?.settings?.settings?.palette?.modules || {}).map(([name, version]) => ({ name, version })) + const activeModulesInstalled = Object.entries(activeSnapshot?.settings?.modules || {}).map(([name, version]) => ({ name, version })) + const defaultModules = device.ownerType === device.getDefaultModules() + if (activeModulesInstalled?.length) { + activeModulesInstalled.forEach(m => { + deps[m.name] = deps[m.name] || {} + deps[m.name].installed = m.version + }) + } + if (targetModulesSemver?.length) { + targetModulesSemver.forEach(m => { + deps[m.name] = deps[m.name] || {} + deps[m.name].semver = m.version + }) + } else if (activeModulesSemver?.length) { + activeModulesSemver.forEach(m => { + deps[m.name] = deps[m.name] || {} + deps[m.name].semver = m.version + }) + } else if (device.ownerType === 'application' && !targetSnapshot && !activeSnapshot) { + // if the device has no snapshots, use the default snapshot data + Object.entries(defaultModules).forEach(([name, version]) => { + deps[name] = deps[name] || {} + deps[name].semver = version + }) + } + + // some devices dont get informed of the @flowfuse/nr-project-nodes or '@flowfuse/nr-assistant' to install due being included + // via nodesdir or other means. In this case, we will use the installed version as the semver + if (deps['@flowfuse/nr-project-nodes'] && deps['@flowfuse/nr-project-nodes'].installed && !deps['@flowfuse/nr-project-nodes'].semver) { + deps['@flowfuse/nr-project-nodes'].semver = deps['@flowfuse/nr-project-nodes'].installed + } + if (deps['@flowfuse/nr-assistant'] && deps['@flowfuse/nr-assistant'].installed && !deps['@flowfuse/nr-assistant'].semver) { + deps['@flowfuse/nr-assistant'].semver = deps['@flowfuse/nr-assistant'].installed + } + + const noderedVersionInstalled = await getDeviceNodeRedVersion(device, activeModulesInstalled) || '*' + const noderedVersionSemver = await getDeviceNodeRedVersion(device, targetModulesSemver) || '*' + deps['node-red'] = { + semver: noderedVersionSemver, + installed: noderedVersionInstalled + } + + child.dependencies = deps + } + } + + return Array.from(children.values()) } } } diff --git a/forge/db/models/Device.js b/forge/db/models/Device.js index 8a262f8950..444efccef7 100644 --- a/forge/db/models/Device.js +++ b/forge/db/models/Device.js @@ -4,6 +4,8 @@ */ const crypto = require('crypto') +const SemVer = require('semver') + const { DataTypes, Op } = require('sequelize') const Controllers = require('../controllers') @@ -205,6 +207,21 @@ module.exports = { limit: 1 }) return snapshots[0] + }, + getDefaultNodeRedVersion () { + let nodeRedVersion = '3.0.2' // default to older Node-RED + if (SemVer.satisfies(SemVer.coerce(this.agentVersion), '>=1.11.2')) { + // 1.11.2 includes fix for ESM loading of GOT, so lets use 'latest' as before + nodeRedVersion = 'latest' + } + return nodeRedVersion + }, + getDefaultModules () { + return { + 'node-red': this.getDefaultNodeRedVersion(), + '@flowfuse/nr-project-nodes': '>0.5.0', // TODO: get this from the "settings" (future) + '@flowfuse/nr-assistant': '>=0.1.0' + } } }, static: { diff --git a/forge/db/views/Application.js b/forge/db/views/Application.js index 6a94dad5fb..6a5f922b30 100644 --- a/forge/db/views/Application.js +++ b/forge/db/views/Application.js @@ -151,6 +151,17 @@ module.exports = function (app) { })) } + app.addSchema({ + $id: 'ApplicationBom', + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + children: { type: 'array', items: { $ref: 'dependant' } } // dependant is defined in BOM.js + }, + additionalProperties: true + }) + return { application, applicationAssociationsStatusList, diff --git a/forge/db/views/BOM.js b/forge/db/views/BOM.js new file mode 100644 index 0000000000..d6f10a84ec --- /dev/null +++ b/forge/db/views/BOM.js @@ -0,0 +1,72 @@ +let app + +module.exports = { + init: (appInstance) => { + app = appInstance + app.addSchema({ + $id: 'dependency', + type: 'object', + properties: { + name: { type: 'string' }, + version: { + type: 'object', + properties: { + semver: { type: 'string' }, + installed: { type: 'string', nullable: true } + } + } + } + }) + app.addSchema({ + $id: 'dependant', + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + type: { + type: 'string', + enum: ['instance', 'device'] + }, + ownerType: { + type: 'string', + enum: ['instance', 'application'], + nullable: true + }, + ownerId: { type: 'string', nullable: true }, + dependencies: { type: 'array', items: { $ref: 'dependency' } } + } + }) + }, + + dependency (name, semverVersion, installedVersion = null) { + const result = { + name, + version: { + semver: semverVersion, + installed: installedVersion || null + } + } + return result + }, + + dependant (model, dependencies) { + const type = model instanceof app.db.models.Project ? 'instance' : model instanceof app.db.models.Device ? 'device' : null + if (type !== null) { + const dependenciesArray = Object.entries(dependencies || {}).map(([name, version]) => app.db.views.BOM.dependency(name, version?.semver, version?.installed)) + if (type === 'device') { + const { hashid, name, ownerType } = model + let ownerId = null + if (ownerType === 'instance') { + ownerId = model.ProjectId + } else if (ownerType === 'application') { + ownerId = model.Application ? model.Application.id : app.db.models.Application.encodeHashid(model.ApplicationId) + } + return { id: hashid, name, type, ownerType, ownerId, dependencies: dependenciesArray } + } else if (type === 'instance') { + const { id, name } = model + return { id, name, type, dependencies: dependenciesArray } + } + } + return null + } +} diff --git a/forge/db/views/index.js b/forge/db/views/index.js index 4fdc01ca34..03a1691fcd 100644 --- a/forge/db/views/index.js +++ b/forge/db/views/index.js @@ -14,6 +14,7 @@ const modelTypes = [ 'AccessToken', 'Application', 'AuditLog', + 'BOM', 'Device', 'DeviceGroup', 'Invitation', diff --git a/forge/ee/lib/index.js b/forge/ee/lib/index.js index 2919d6d843..519d78d75a 100644 --- a/forge/ee/lib/index.js +++ b/forge/ee/lib/index.js @@ -17,6 +17,8 @@ module.exports = fp(async function (app, opts) { app.config.features.register('mfa', true, true) // Set the Device Groups Feature Flag app.config.features.register('deviceGroups', true, true) + // Set the Bill of Materials Feature Flag + app.config.features.register('bom', true, true) } // Set the Team Library Feature Flag diff --git a/forge/ee/routes/bom/application.js b/forge/ee/routes/bom/application.js new file mode 100644 index 0000000000..229306b939 --- /dev/null +++ b/forge/ee/routes/bom/application.js @@ -0,0 +1,47 @@ +const applicationShared = require('../../../routes/api/shared/application.js') + +module.exports = async function (app) { + app.addHook('preHandler', applicationShared.defaultPreHandler.bind(null, app)) + + /** + * Get the application BOM + * @name /api/v1/application/:applicationId/bom + * @memberof forge.routes.api.application + */ + app.get('/:applicationId/bom', { + preHandler: app.needsPermission('application:bom'), + schema: { + summary: 'Get application BOM', + tags: ['Applications'], + params: { + type: 'object', + properties: { + applicationId: { type: 'string' } + } + }, + response: { + 200: { + type: 'object', + $ref: 'ApplicationBom' + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + const teamType = await request.application.Team.getTeamType() + if (!teamType.getFeatureProperty('bom', false)) { + return reply.code(404).send({ code: 'unexpected_error', error: 'Feature not enabled.' }) + } + + const dependants = await request.application.getChildren({ includeDependencies: true }) + const childrenView = dependants.map(child => app.db.views.BOM.dependant(child.model, child.dependencies)) + const result = { + id: request.application.hashid, + name: request.application.name, + children: childrenView + } + reply.send(result) + }) +} diff --git a/forge/ee/routes/index.js b/forge/ee/routes/index.js index 003598c56a..b6e75a483d 100644 --- a/forge/ee/routes/index.js +++ b/forge/ee/routes/index.js @@ -12,6 +12,7 @@ module.exports = async function (app) { await app.register(require('./sharedLibrary'), { logLevel: app.config.logging.http }) await app.register(require('./pipeline'), { prefix: '/api/v1', logLevel: app.config.logging.http }) await app.register(require('./deviceEditor'), { prefix: '/api/v1/devices/:deviceId/editor', logLevel: app.config.logging.http }) + await app.register(require('./bom/application.js'), { prefix: '/api/v1/applications', logLevel: app.config.logging.http }) await app.register(require('./flowBlueprints'), { prefix: '/api/v1/flow-blueprints', logLevel: app.config.logging.http }) diff --git a/forge/lib/permissions.js b/forge/lib/permissions.js index 0a9c55b273..40ccc687b0 100644 --- a/forge/lib/permissions.js +++ b/forge/lib/permissions.js @@ -120,6 +120,9 @@ const Permissions = { /** * EE Permissions */ + // Application + 'application:bom': { description: 'Get the Bill of Materials', role: Roles.Owner }, + // Device Groups 'application:device-group:create': { description: 'Create a device group', role: Roles.Owner }, 'application:device-group:list': { description: 'List device groups', role: Roles.Member }, diff --git a/forge/routes/api/application.js b/forge/routes/api/application.js index 38958a93e6..5e4dfaadbe 100644 --- a/forge/routes/api/application.js +++ b/forge/routes/api/application.js @@ -1,30 +1,7 @@ -module.exports = async function (app) { - app.addHook('preHandler', async (request, reply) => { - const applicationId = request.params.applicationId - if (applicationId === undefined) { - return - } - - if (!applicationId) { - return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) - } - - try { - request.application = await app.db.models.Application.byId(applicationId) - if (!request.application) { - return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) - } +const applicationShared = require('./shared/application.js') - if (request.session.User) { - request.teamMembership = await request.session.User.getTeamMembership(request.application.Team.id) - if (!request.teamMembership && !request.session.User.admin) { - return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) - } - } - } catch (err) { - return reply.code(500).send({ code: 'unexpected_error', error: err.toString() }) - } - }) +module.exports = async function (app) { + app.addHook('preHandler', applicationShared.defaultPreHandler.bind(null, app)) /** * Create an application diff --git a/forge/routes/api/deviceLive.js b/forge/routes/api/deviceLive.js index 3f20cc99ed..b4239ff85b 100644 --- a/forge/routes/api/deviceLive.js +++ b/forge/routes/api/deviceLive.js @@ -114,23 +114,10 @@ module.exports = async function (app) { obj.modules['node-red'] = editor?.nodeRedVersion } } - const getDefaultNodeRedVersion = (dev) => { - let nodeRedVersion = '3.0.2' // default to older Node-RED - if (SemVer.satisfies(SemVer.coerce(dev.agentVersion), '>=1.11.2')) { - // 1.11.2 includes fix for ESM loading of GOT, so lets use 'latest' as before - nodeRedVersion = 'latest' - } - if (SemVer.satisfies(SemVer.coerce(dev.agentVersion), '>=1.11.2')) { - // 1.11.2 includes fix for ESM loading of GOT, so lets use 'latest' as before - nodeRedVersion = 'latest' - } - return nodeRedVersion - } if (!device.targetSnapshot) { // device does not have a target snapshot // if this is an application owned device, return a starter snapshot if (device.isApplicationOwned) { - const nodeRedVersion = getDefaultNodeRedVersion(device) if (!device.agentVersion || SemVer.lt(device.agentVersion, '1.11.0')) { reply.code(400).send({ code: 'invalid_agent_version', error: 'invalid agent version' }) return @@ -147,13 +134,7 @@ module.exports = async function (app) { { id: 'FFCHA00000000001', type: 'change', z: 'FFF0000000000001', name: 'Get Env Vars', rules: [{ t: 'set', p: 'payload', pt: 'msg', to: '{}', tot: 'json' }, { t: 'set', p: 'payload.device', pt: 'msg', to: 'FF_DEVICE_NAME', tot: 'env' }, { t: 'set', p: 'payload.application', pt: 'msg', to: 'FF_APPLICATION_NAME', tot: 'env' }], action: '', reg: false, x: 320, y: 160, wires: [['FFDBG00000000001']] }, { id: 'FFDBG00000000001', type: 'debug', z: 'FFF0000000000001', name: 'Info', active: true, tosidebar: true, console: true, tostatus: true, complete: 'payload', targetType: 'msg', statusVal: 'payload', statusType: 'auto', x: 490, y: 160 } ], - modules: { - 'node-red': nodeRedVersion, - // as of FF v1.14.0, we permit project nodes to work on application owned devices - // the support for this is in @flowfuse/nr-project-nodes > v0.5.0 - '@flowfuse/nr-project-nodes': '>0.5.0', // TODO: get this from the "settings" (future) - '@flowfuse/nr-assistant': '>=0.1.0' - }, + modules: device.getDefaultModules(), env: { FF_SNAPSHOT_ID: '0', FF_SNAPSHOT_NAME: 'None', @@ -186,18 +167,19 @@ module.exports = async function (app) { // as of FF v1.14.0, we permit project nodes to work on application owned devices if (device.isApplicationOwned) { - settings.modules = settings.modules || {} // snapshot might not have any modules + const defaultModules = device.getDefaultModules() + settings.modules = settings.modules || defaultModules // snapshot might not have any modules // @flowfuse/nr-project-nodes > v0.5.0 is required for this to work // if the snapshot does not have the new module specified OR it is a version <= 0.5.0, update it if (!settings.modules['@flowfuse/nr-project-nodes'] || SemVer.satisfies(SemVer.coerce(settings.modules['@flowfuse/nr-project-nodes']), '<=0.5.0')) { - settings.modules['@flowfuse/nr-project-nodes'] = '>0.5.0' + settings.modules['@flowfuse/nr-project-nodes'] = defaultModules['@flowfuse/nr-project-nodes'] || '>0.5.0' } if (!settings.modules['@flowfuse/nr-assistant']) { - settings.modules['@flowfuse/nr-assistant'] = '>=0.1.0' + settings.modules['@flowfuse/nr-assistant'] = defaultModules['@flowfuse/nr-assistant'] || '>=0.1.0' } if (!settings.modules['node-red']) { // if the snapshot does not have the node-red module specified, ensure it is set to a valid version - settings.modules['node-red'] = getDefaultNodeRedVersion(device) + settings.modules['node-red'] = defaultModules['node-red'] || device.getDefaultNodeRedVersion() } // Belt and braces, remove old module! We don't want to be instructing the device to install the old version. // (the old module can be present due to a snapshot applied from an instance or instance owned device) diff --git a/forge/routes/api/shared/application.js b/forge/routes/api/shared/application.js new file mode 100644 index 0000000000..6d307d4610 --- /dev/null +++ b/forge/routes/api/shared/application.js @@ -0,0 +1,28 @@ +module.exports = { + defaultPreHandler: async (app, request, reply) => { + const applicationId = request.params.applicationId + if (applicationId === undefined) { + return + } + + if (!applicationId) { + return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + + try { + request.application = await app.db.models.Application.byId(applicationId) + if (!request.application) { + return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + + if (request.session.User) { + request.teamMembership = await request.session.User.getTeamMembership(request.application.Team.id) + if (!request.teamMembership && !request.session.User.admin) { + return reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + } + } catch (err) { + return reply.code(500).send({ code: 'unexpected_error', error: err.toString() }) + } + } +} diff --git a/frontend/src/api/application.js b/frontend/src/api/application.js index a6704b8107..22919a28b5 100644 --- a/frontend/src/api/application.js +++ b/frontend/src/api/application.js @@ -342,6 +342,16 @@ const updateDeviceGroupMembership = async (applicationId, groupId, { add, remove return client.patch(`/api/v1/applications/${applicationId}/device-groups/${groupId}`, { add, remove, set }) } +/** + * Get a list of Dependencies / Bill of Materials + * @param applicationId + * @returns {Promise>} + */ +const getDependencies = (applicationId) => { + return client.get(`/api/v1/applications/${applicationId}/bom`) + .then(res => res.data) +} + export default { createApplication, updateApplication, @@ -362,5 +372,6 @@ export default { createDeviceGroup, deleteDeviceGroup, updateDeviceGroup, - updateDeviceGroupMembership + updateDeviceGroupMembership, + getDependencies } diff --git a/frontend/src/api/external.js b/frontend/src/api/external.js new file mode 100644 index 0000000000..42324c8195 --- /dev/null +++ b/frontend/src/api/external.js @@ -0,0 +1,12 @@ +import client from './client.js' + +const getNpmDependency = async (dependency, version = '') => { + return client.get(`https://registry.npmjs.com/${dependency}/${version}`) + .then(res => { + return res.data + }) +} + +export default { + getNpmDependency +} diff --git a/frontend/src/components/Accordion.vue b/frontend/src/components/Accordion.vue index 2f4e6835cb..0a3f4a4496 100644 --- a/frontend/src/components/Accordion.vue +++ b/frontend/src/components/Accordion.vue @@ -1,8 +1,10 @@