Skip to content

Commit

Permalink
Merge branch 'main' into app-routes-follow-up
Browse files Browse the repository at this point in the history
  • Loading branch information
cstns authored Jan 16, 2025
2 parents 8c97a29 + 46cdfcc commit b066b66
Show file tree
Hide file tree
Showing 30 changed files with 1,454 additions and 51 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ on:
- maintenance
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
check-changes:
name: Verify changes
Expand Down
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,47 @@
#### 2.13.0: Release

- ci: Fix `Tests` pipeline summary generation (#4988)
- Add a team level device groups UI (#5018) @cstns
- Disable caching of index.html when in dev mode (#5017) @knolleary
- Update Team Device Groups routes to use hashid not slug (#5016) @knolleary
- Add a team level device groups API (#5009) @cstns
- Set pending state when loggin in to prevent no team limbo (#5012) @cstns
- ci: Enable concurrency in `Tests` workflow (#5006) @ppawlowski
- Add Open Schema button to Topic Hierarchy view (#5008) @knolleary
- Use named routes for the application summary labels (#5010) @cstns
- Enrich crash email with detail and hints where crash reason can be inferred (#4936) @Steve-Mcl
- Fix silently failing featuresCheck due to missing team (#5005) @cstns
- Serve seo tags for the login and signup pages prerendered (#5000) @cstns
- Add initial schema generation for team-broker topics (#4997) @knolleary
- Add Team Broker hostname to UI settings (#4998) @hardillb
- ci: Add `nr-file-nodes` package build step to the pre-staging deployment (#4995) @ppawlowski
- Remove the injected canonical link (#4994) @cstns
- Fix console errors when logging in due to the team not being loaded (#4993) @cstns
- Change team before switching route when accessing team link from the admin page (#4992) @cstns
- Update text on create button for trial teams (#4986) @knolleary
- Fix topic copy button (#4991) @knolleary
- Updated Onboarding Tours (#4979) @joepavitt
- Revert "Create check-tests-status job summary" (#4989) @ppawlowski
- ci: Create `check-tests-status` job summary (#4987) @ppawlowski
- Add option to auto-create team application (#4985) @knolleary
- Update persistent-context.md (#4984) @sumitshinde-84
- ci: Improve notification on tests failures (#4971) @ppawlowski
- "Devices" & "Edge Instances" > Remote Instances (#4976) @joepavitt
- Ensure Pipelines don't fall over if Device Groups are unavailable (#4975) @joepavitt
- Show "Expired" for expired licenses (#4967) @hardillb
- Add tooltips to "Open Editor" button for Devices (#4973) @joepavitt
- Fix broken Device Application Link (#4972) @joepavitt
- Improve feedback when unable to connect to Device Logs (#4974) @joepavitt
- ci: Add `nr-project-nodes` package build step to the pre-staging deployment pipeline (#4968) @ppawlowski
- Only hash httpNodeAuth Password if not already hashed (#4966) @hardillb
- Remove the term "Free Trial" from the welcome dialog (#4962) @joepavitt
- Make sure the Team Types are ordered correctly when changing team type (#4961) @joepavitt
- Improve feedback when Hosted Instances are not available to a team (#4956) @joepavitt
- Fixes setMainNavBackButton race condition before a team is present after logging in (#4949) @cstns
- Fix race condition when changing teams while on the application page (#4951) @cstns
- Allow trial team to be manually created (#4941) @knolleary
- Bump semver from 7.6.0 to 7.6.3 (#4925) @app/dependabot

#### 2.12.0: Release

- Add note about Private CA chain (#4901)
Expand Down
28 changes: 24 additions & 4 deletions forge/db/models/DeviceGroup.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,27 @@ module.exports = {
}
})
},
getAll: async (pagination = {}, where = {}) => {
getAll: async (pagination = {}, where = {}, options = {}) => {
const { includeApplication = false } = options
const limit = parseInt(pagination.limit) || 1000

if (pagination.cursor) {
pagination.cursor = M.DeviceGroup.decodeHashid(pagination.cursor)
}
if (where.ApplicationId && typeof where.ApplicationId === 'string') {
where.ApplicationId = M.Application.decodeHashid(where.ApplicationId)

if (where.ApplicationId) {
if (typeof where.ApplicationId === 'string') {
where.ApplicationId = M.Application.decodeHashid(where.ApplicationId)
} else if (Array.isArray(where.ApplicationId)) {
where.ApplicationId = where.ApplicationId.map(hashId => {
if (typeof hashId === 'string') {
return M.Application.decodeHashid(hashId)
}
return hashId
})
}
}

const [rows, count] = await Promise.all([
this.findAll({
where: buildPaginationSearchClause(pagination, where, ['DeviceGroup.name', 'DeviceGroup.description']),
Expand All @@ -130,7 +143,14 @@ module.exports = {
model: M.ProjectSnapshot,
as: 'targetSnapshot',
attributes: ['hashid', 'id', 'name']
}
},
...(includeApplication
? [{
model: M.Application,
as: 'Application',
attributes: ['hashid', 'id', 'name']
}]
: [])
],
attributes: {
include: [
Expand Down
14 changes: 13 additions & 1 deletion forge/db/views/DeviceGroup.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,32 @@ module.exports = function (app) {
targetSnapshot: {
nullable: true,
allOf: [{ $ref: 'SnapshotSummary' }]
},
application: {
nullable: true,
allOf: [{ $ref: 'ApplicationSummary' }]
}
}
})
function deviceGroupSummary (group) {
function deviceGroupSummary (group, options = {}) {
const { includeApplication = false } = options

if (group.toJSON) {
group = group.toJSON()
}

const result = {
id: group.hashid,
name: group.name,
description: group.description,
deviceCount: group.deviceCount || 0,
targetSnapshot: app.db.views.ProjectSnapshot.snapshotSummary(group.targetSnapshot)
}

if (includeApplication && group.Application) {
result.application = app.db.views.Application.applicationSummary(group.Application)
}

return result
}

Expand Down
26 changes: 23 additions & 3 deletions forge/ee/lib/alerts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,34 @@ module.exports = {
if (app.postoffice.enabled) {
app.config.features.register('emailAlerts', true, true)
app.auditLog.alerts = {}
app.auditLog.alerts.generate = async function (projectId, event) {
app.auditLog.alerts.generate = async function (projectId, event, data) {
if (app.postoffice.enabled) {
const project = await app.db.models.Project.byId(projectId)
const settings = await app.db.controllers.Project.getRuntimeSettings(project)
const teamType = await app.db.models.TeamType.byId(project.Team.TeamTypeId)
const emailAlerts = settings.emailAlerts
let template
if (emailAlerts?.crash && event === 'crashed') {
template = 'Crashed'
const templateName = ['Crashed']
const hasLogs = data?.log?.length > 0
let uncaughtException = false
let outOfMemory = false
if (hasLogs) {
uncaughtException = data.exitCode > 0 && data.log.some(log => {
const lcMsg = log.msg?.toLowerCase() || ''
return lcMsg.includes('uncaughtexception') || log.msg.includes('uncaught exception')
})
outOfMemory = data.exitCode > 127 && data.log.some(log => {
const lcMsg = log.msg?.toLowerCase() || ''
return lcMsg.includes('heap out of memory') || lcMsg.includes('v8::internal::heap::')
})
}
if (outOfMemory) {
templateName.push('out-of-memory')
} else if (uncaughtException) {
templateName.push('uncaught-exception')
}
template = templateName.join('-')
} else if (emailAlerts?.safe && event === 'safe-mode') {
template = 'SafeMode'
}
Expand All @@ -42,9 +61,10 @@ module.exports = {
break
}
const users = (await app.db.models.TeamMember.findAll({ where, include: app.db.models.User })).map(tm => tm.User)
const teamName = project.Team?.name || ''
if (users.length > 0) {
users.forEach(user => {
app.postoffice.send(user, template, { name: project.name, url: `${app.config.base_url}/instance/${project.id}` })
app.postoffice.send(user, template, { ...data, name: project.name, teamName, url: `${app.config.base_url}/instance/${project.id}` })
})
}
}
Expand Down
1 change: 1 addition & 0 deletions forge/ee/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ module.exports = async function (app) {

if (app.license.get('tier') === 'enterprise') {
await app.register(require('./applicationDeviceGroups'), { prefix: '/api/v1/applications/:applicationId/device-groups', logLevel: app.config.logging.http })
await app.register(require('./teamDeviceGroups'), { prefix: '/api/v1/teams/:teamId/device-groups', logLevel: app.config.logging.http })
await app.register(require('./ha'), { prefix: '/api/v1/projects/:projectId/ha', logLevel: app.config.logging.http })
await app.register(require('./protectedInstance'), { prefix: '/api/v1/projects/:projectId/protectInstance', logLevel: app.config.logging.http })
await app.register(require('./mfa'), { prefix: '/api/v1', logLevel: app.config.logging.http })
Expand Down
5 changes: 4 additions & 1 deletion forge/ee/routes/teamBroker/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ module.exports = async function (app) {
schema.servers = {
'team-broker': {
host: teamBrokerHost,
protocol: 'mqtt'
protocol: 'mqtt',
security: [{
type: 'userPassword'
}]
}
}
}
Expand Down
94 changes: 94 additions & 0 deletions forge/ee/routes/teamDeviceGroups/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Team DeviceGroup api routes
*
* - /api/v1/teams/:teamId/device-groups
*
* @namespace teams
* @memberof forge.routes.api
*/

/**
* @param {import('../../../forge.js').ForgeApplication} app The application instance
*/
module.exports = async function (app) {
// pre-handler for all routes in this file
app.addHook('preHandler', async (request, reply) => {
// Get the team
const teamId = request.params.teamId
if (!teamId) {
return reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}

try {
request.team = await app.db.models.Team.byId(request.params.teamId)
if (!request.team) {
return reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}

if (request.session.User) {
request.teamMembership = await request.session.User.getTeamMembership(request.team.id)
if (!request.teamMembership && !request.session.User.admin) {
return reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
}

const teamType = await request.team.getTeamType()
if (!teamType.getFeatureProperty('deviceGroups', false)) {
return reply.code(404).send({ code: 'not_found', error: 'Not Found' })
}
} catch (err) {
return reply.code(500).send({ code: 'unexpected_error', error: err.toString() })
}
})

/**
* Get a list of team device groups
* @method GET
* @name /api/v1/teams/:teamId/device-groups
* @memberof forge.routes.api.team
*/
app.get('/', {
preHandler: app.needsPermission('team:device-group:list'),
schema: {
summary: 'Get a list of device groups in an application',
tags: ['Application Device Groups'],
query: { $ref: 'PaginationParams' },
params: {
type: 'object',
properties: {
applicationId: { type: 'string' }
}
},
response: {
200: {
type: 'object',
properties: {
meta: { $ref: 'PaginationMeta' },
count: { type: 'number' },
groups: { type: 'array', items: { $ref: 'DeviceGroupSummary' } }
}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const paginationOptions = app.db.controllers.Device.getDevicePaginationOptions(request)

const applications = await app.db.models.Application.byTeam(request.team.id)

const where = {
ApplicationId: applications.map(app => app.dataValues.id)
}

const groupData = await app.db.models.DeviceGroup.getAll(paginationOptions, where, { includeApplication: true })
const result = {
count: groupData.count,
meta: groupData.meta,
groups: (groupData.groups || []).map(d => app.db.views.DeviceGroup.deviceGroupSummary(d, { includeApplication: true }))
}

reply.send(result)
})
}
1 change: 1 addition & 0 deletions forge/lib/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ const Permissions = {

// Team
'team:bom': { description: 'Get the Team Bill of Materials', role: Roles.Owner },
'team:device-group:list': { description: 'List Team device groups', role: Roles.Member },

// Device Groups
'application:device-group:create': { description: 'Create a device group', role: Roles.Owner },
Expand Down
49 changes: 49 additions & 0 deletions forge/postoffice/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,50 @@ module.exports = fp(async function (app, _opts) {
html: value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\./g, '<br style="display: none;"/>.')
}
}
/**
* Generates email-safe versions (both text and html) of a log array
* This is intended to make iso time strings and and sanitized log messages
* @param {Array<{ts: Number, level: String, msg: String}>} log
*/
function sanitizeLog (log) {
const isoTime = (ts) => {
if (!ts) return ''
try {
let dt
if (typeof ts === 'string') {
ts = +ts
}
// cater for ts with a 4 digit counter appended to the timestamp
if (ts > 99999999999999) {
dt = new Date(ts / 10000)
} else {
dt = new Date(ts)
}
let str = dt.toISOString().replace('T', ' ').replace('Z', '')
str = str.substring(0, str.length - 4) // remove milliseconds
return str
} catch (e) {
return ''
}
}
const htmlEscape = (str) => (str + '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return {
text: log.map(entry => {
return {
timestamp: entry.ts ? isoTime(+entry.ts) : '',
level: entry.level || '',
message: entry.msg || ''
}
}),
html: log.map(entry => {
return {
timestamp: entry.ts ? isoTime(+entry.ts) : '',
level: htmlEscape(entry.level || ''),
message: htmlEscape(entry.msg || '')
}
})
}
}

/**
* Send an email to a user
Expand All @@ -159,6 +203,11 @@ module.exports = fp(async function (app, _opts) {
if (templateContext.invitee) {
templateContext.invitee = sanitizeText(templateContext.invitee)
}
if (Array.isArray(templateContext.log) && templateContext.log.length > 0) {
templateContext.log = sanitizeLog(templateContext.log)
} else {
delete templateContext.log
}
const mail = {
to: user.email,
subject: template.subject(templateContext, { allowProtoPropertiesByDefault: true, allowProtoMethodsByDefault: true }),
Expand Down
Loading

0 comments on commit b066b66

Please sign in to comment.