@flowfuse/flowfuse
Version:
An open source low-code development platform
338 lines (315 loc) • 17.8 kB
JavaScript
/**
* An application definition
* @namespace forge.db.models.Application
*/
const { col, fn, DataTypes, Op, literal, where } = require('sequelize')
const { KEY_SETTINGS, KEY_HA } = require('./ProjectSettings')
module.exports = {
name: 'Application',
schema: {
name: { type: DataTypes.STRING, allowNull: false },
description: { type: DataTypes.STRING, defaultValue: '' }
},
hooks: function (M, app) {
return {
afterDestroy: async (application, opts) => {
const where = {
ApplicationId: application.id
}
M.Device.update({ ApplicationId: null }, { where })
}
}
},
associations: function (M) {
this.hasMany(M.Project)
this.hasMany(M.Project, { as: 'Instances' })
this.belongsTo(M.Team, { foreignKey: { allowNull: false } })
this.hasMany(M.DeviceGroup, { onDelete: 'CASCADE' })
this.hasMany(M.Device) // also via instance and device group
},
finders: function (M, app) {
return {
static: {
byId: async function (idOrHash) {
let id = idOrHash
if (typeof idOrHash === 'string') {
id = M.Application.decodeHashid(idOrHash)
}
return this.findOne({
where: { id },
include: [
{
model: M.Team,
attributes: ['hashid', 'id', 'name', 'slug', 'links', 'TeamTypeId']
}
]
})
},
byTeam: async (teamIdOrHash, { query = null, applicationId = null, includeInstances = false, includeApplicationDevices = false, includeInstanceStorageFlow = false, associationsLimit = null, includeApplicationSummary = false } = {}) => {
let id = teamIdOrHash
if (typeof teamIdOrHash === 'string') {
id = M.Team.decodeHashid(teamIdOrHash)
}
const includes = [
{
model: M.Team,
attributes: ['hashid', 'id', 'name', 'slug', 'links', 'TeamTypeId'],
where: { id }
}
]
if (includeInstances) {
const include = {
model: M.Project,
as: 'Instances',
attributes: ['hashid', 'id', 'name', 'slug', 'links', 'url', 'state'],
include: [
// Need for project URL calculation (depends on httpAdminRoot)
{
model: M.ProjectTemplate,
attributes: ['hashid', 'id', 'name', 'links', 'settings', 'policy']
}, {
model: M.ProjectSettings,
where: {
[Op.or]: [
{ key: KEY_SETTINGS },
{ key: KEY_HA }
]
},
required: false
}
]
}
if (includeInstanceStorageFlow) {
// Used for instance status
include.include.push({
model: M.StorageFlow,
attributes: ['id', 'updatedAt']
})
}
if (associationsLimit) {
include.limit = associationsLimit
include.order = [['updatedAt', 'DESC']]
include.attributes = {
include: [...include.attributes]
}
}
includes.push(include)
}
if (includeApplicationDevices) {
const include = {
model: M.Device,
attributes: ['hashid', 'id', 'name', 'type', 'links', 'state', 'mode', 'updatedAt', 'lastSeenAt', 'editorConnected', 'editorToken', 'ownerType', 'ProjectId', 'ApplicationId']
}
if (associationsLimit) {
include.limit = associationsLimit
include.order = [['lastSeenAt', 'DESC NULLS LAST'], ['updatedAt', 'DESC']]
}
includes.push(include)
}
const queryObject = {
include: includes
}
const queryWheres = []
if (applicationId) {
if (typeof applicationId === 'string') {
applicationId = M.Application.decodeHashid(applicationId)
}
queryWheres.push({ id: applicationId })
} else if (query) {
queryWheres.push({
[Op.or]: [
where(fn('lower', col('Application.name')), { [Op.like]: `%${query.toLowerCase()}%` }),
where(fn('lower', col('Application.description')), { [Op.like]: `%${query.toLowerCase()}%` })
]
})
}
if (queryWheres.length === 1) {
queryObject.where = queryWheres[0]
} else if (queryWheres.length > 1) {
queryObject.where = { [Op.and]: queryWheres }
}
if (includeApplicationSummary) {
queryObject.attributes = {
include: [
[
literal(`(
SELECT COUNT(*)
FROM "Projects" AS "Instances"
WHERE "Instances"."ApplicationId" = "Application"."id"
)`),
'instanceCount'
],
[
literal(`(
SELECT COUNT(*)
FROM "Devices"
WHERE "Devices"."ApplicationId" = "Application"."id"
)`),
'deviceCount'
],
[
literal(`(
SELECT count(*)
FROM "DeviceGroups"
WHERE "DeviceGroups"."ApplicationId" = "Application"."id"
)`),
'deviceGroupCount'
],
[
literal(`(
SELECT count(*)
FROM "ProjectSnapshots"
LEFT JOIN "Devices" ON "Devices"."id" = "ProjectSnapshots"."DeviceId"
LEFT JOIN "Projects" ON "Projects"."id" = "ProjectSnapshots"."ProjectId"
WHERE "Devices"."ApplicationId" = "Application"."id" OR "Projects"."ApplicationId" = "Application"."id"
)`),
'snapshotCount'
]
]
}
// Non-EE licensed instances might not have the Pipeline table
// You can add a license without a restart, so we also need to check if the Model is loaded
// If the model is loaded, it can be assumed the table exists
if (app.license.active() && app.db.models.Pipeline) {
queryObject.attributes.include.push([
literal(`(
SELECT count(*)
FROM "Pipelines"
WHERE "Pipelines"."ApplicationId" = "Application"."id"
)`),
'pipelineCount'
])
}
}
return this.findAll(queryObject)
}
},
instance: {
projectCount: async function () {
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'] = {
wanted: instance.versions?.['node-red']?.wanted,
current: instance.versions?.['node-red']?.current
}
const settings = await instance.getSetting(KEY_SETTINGS)
if (Array.isArray(settings?.palette?.modules)) {
settings.palette.modules.forEach(m => {
deps[m.name] = {
wanted: m.version
}
})
}
const projectModules = await storageController.getProjectModules(child.model) || []
projectModules.forEach(m => {
deps[m.name] = deps[m.name] || {}
deps[m.name].current = m.version
if (!deps[m.name].wanted) {
deps[m.name].wanted = 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].current = m.version
})
}
if (targetModulesSemver?.length) {
targetModulesSemver.forEach(m => {
deps[m.name] = deps[m.name] || {}
deps[m.name].wanted = m.version
})
} else if (activeModulesSemver?.length) {
activeModulesSemver.forEach(m => {
deps[m.name] = deps[m.name] || {}
deps[m.name].wanted = 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].wanted = 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'].current && !deps['@flowfuse/nr-project-nodes'].wanted) {
deps['@flowfuse/nr-project-nodes'].wanted = deps['@flowfuse/nr-project-nodes'].current
}
if (deps['@flowfuse/nr-assistant'] && deps['@flowfuse/nr-assistant'].current && !deps['@flowfuse/nr-assistant'].wanted) {
deps['@flowfuse/nr-assistant'].wanted = deps['@flowfuse/nr-assistant'].current
}
const noderedVersionInstalled = await getDeviceNodeRedVersion(device, activeModulesInstalled) || '*'
const noderedVersionSemver = await getDeviceNodeRedVersion(device, targetModulesSemver) || '*'
deps['node-red'] = {
wanted: noderedVersionSemver,
current: noderedVersionInstalled
}
child.dependencies = deps
}
}
return Array.from(children.values())
}
}
}
}
}