From c8d88ca07cc3e3b3596de40d9984b8df44f1eec2 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Wed, 18 Dec 2024 09:48:59 +0000 Subject: [PATCH 01/47] pass crash detail from API to Postoffice routines --- forge/routes/logging/index.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/forge/routes/logging/index.js b/forge/routes/logging/index.js index 1e6980da42..d43ccacb35 100644 --- a/forge/routes/logging/index.js +++ b/forge/routes/logging/index.js @@ -47,6 +47,8 @@ module.exports = async function (app) { const auditEvent = request.body const event = auditEvent.event const error = auditEvent.error + const __launcherLog = auditEvent.__launcherLog || [] + delete auditEvent.__launcherLog // dont add this to the audit log // Some node-red audit events are not useful to expose to the end user - filter them out here // api.error:version_mismatch - normal part of collision detection when trying to deploy flows @@ -85,7 +87,15 @@ module.exports = async function (app) { await app.db.controllers.Project.addProjectModule(request.project, auditEvent.module, auditEvent.version || '*') } else if (event === 'crashed' || event === 'safe-mode') { if (app.config.features.enabled('emailAlerts')) { - await app.auditLog.alerts.generate(projectId, event) + const data = event === 'crashed' + ? { + exitCode: auditEvent.info?.code, + exitSignal: auditEvent.info?.signal, + exitInfo: auditEvent.info?.info, + log: __launcherLog + } + : undefined + await app.auditLog.alerts.generate(projectId, event, data) } // send notification to all members and owners in the team const teamMembersAndOwners = await request.project.Team.getTeamMembers([Roles.Member, Roles.Owner]) From 1f26703121917f4c8938bb423a2da9edd783be81 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Wed, 18 Dec 2024 09:50:00 +0000 Subject: [PATCH 02/47] include team name and extra detail in email context blob --- forge/ee/lib/alerts/index.js | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/forge/ee/lib/alerts/index.js b/forge/ee/lib/alerts/index.js index 7540161a65..bc1603ef49 100644 --- a/forge/ee/lib/alerts/index.js +++ b/forge/ee/lib/alerts/index.js @@ -7,7 +7,7 @@ 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) @@ -15,7 +15,26 @@ module.exports = { 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' } @@ -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}` }) }) } } From 7122ed8c3698df4966706322c0d96b62482cb00c Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Wed, 18 Dec 2024 09:50:48 +0000 Subject: [PATCH 03/47] update default chrash template and add specific templates for known crash reasons --- .../templates/Crashed-out-of-memory.js | 86 +++++++++++++++++++ .../templates/Crashed-uncaught-exception.js | 81 +++++++++++++++++ forge/postoffice/templates/Crashed.js | 44 +++++++++- 3 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 forge/postoffice/templates/Crashed-out-of-memory.js create mode 100644 forge/postoffice/templates/Crashed-uncaught-exception.js diff --git a/forge/postoffice/templates/Crashed-out-of-memory.js b/forge/postoffice/templates/Crashed-out-of-memory.js new file mode 100644 index 0000000000..d1e216ba6c --- /dev/null +++ b/forge/postoffice/templates/Crashed-out-of-memory.js @@ -0,0 +1,86 @@ +module.exports = { + subject: 'FlowFuse Instance crashed', + text: +`Hello + +Your FlowFuse Instance "{{{ name }}}" in Team "{{{ teamName.text }}}" has crashed due to an out of memory error. + +This can occur for a number of reasons including: +- incorrect instance size for your workload +- an issue in your flows or functions holding onto memory +- an issue in a third-party library or node + +Possible solutions: +- try selecting a larger instance type +- try disabling some nodes to see if the problem settles down after a restart +- when polling external services, ensure you are not polling too frequently as this may cause backpressure leading to memory exhaustion +- check your flows for large data structures being held in memory, particularly in context +- check the issue tracker of your contrib nodes + +{{#if log.text}} +------------------------------------------------------ +Logs... + +{{#log.text}} +Timestamp: {{{timestamp}}} +Severity: {{{level}}} +Message: {{{message}}} + +{{/log.text}} + +Note: Timestamps in this log are in UTC (Coordinated Universal Time). +------------------------------------------------------ +{{/if}} + +You can access the instance and its logs here: + +{{{ url }}} + +`, + html: +`

Hello

+

Your FlowFuse Instance "{{{ name }}}" in Team "{{{ teamName.html }}}" has crashed due to an out of memory error.

+ +

+This can occur for a number of reasons including: +

    +
  • incorrect instance size for your workload - try selecting a larger instance type
  • +
  • an issue in your flows holding onto memory - check your flows usage of context
  • +
  • an issue in a third-party library or node - check the issue tracker of your contrib nodes
  • +
+ +Possible solutions: +
    +
  • try selecting a larger instance type
  • +
  • try disabling some nodes to see if the problem settles down after a restart
  • +
  • when polling external services, ensure you are not polling too frequently as this may cause backpressure leading to memory exhaustion
  • +
  • check your flows for large data structures being held in memory, particularly in context
  • +
  • check the issue tracker of your contrib nodes
  • +

    + + +{{#if log.html}} +

    +Logs... + + + + + + + {{#log.html}} + + + + + + {{/log.html}} +
    TimestampSeverityMessage
    {{{timestamp}}}{{{level}}}{{{message}}}
    +Note: Timestamps in this log are in UTC (Coordinated Universal Time). +

    +{{/if}} + +

    You can access the instance and its logs here

    +Instance Logs +` +} diff --git a/forge/postoffice/templates/Crashed-uncaught-exception.js b/forge/postoffice/templates/Crashed-uncaught-exception.js new file mode 100644 index 0000000000..ac26864265 --- /dev/null +++ b/forge/postoffice/templates/Crashed-uncaught-exception.js @@ -0,0 +1,81 @@ +module.exports = { + subject: 'FlowFuse Instance crashed', + text: +`Hello + +Your FlowFuse Instance "{{{ name }}}" in Team "{{{ teamName.text }}}" has crashed due to an uncaught exception. + +This can occur for a number of reasons including: +- an issue in your flows or function nodes +- an issue in a third-party contribution node +- an issue in Node-RED itself + +Possible solutions: +- look out for async function calls in your function nodes that dont have error handling +- check the issue tracker of the node that caused the crash +- check the Node-RED issue tracker for similar issues + +{{#if log.text}} +------------------------------------------------------ +Logs... + +{{#log.text}} +Timestamp: {{{timestamp}}} +Severity: {{{level}}} +Message: {{{message}}} + +{{/log.text}} + +Note: Timestamps in this log are in UTC (Coordinated Universal Time). +------------------------------------------------------ +{{/if}} + +You can access the instance and its logs here: + +{{{ url }}} + +`, + html: +`

    Hello

    +

    Your FlowFuse Instance "{{{ name }}}" in Team "{{{ teamName.html }}}" has crashed due to an uncaught exception.

    + +

    +This can occur for a number of reasons including: +

      +
    • an issue in your flows or function nodes
    • +
    • an issue in a third-party contribution node
    • +
    • an issue in Node-RED itself
    • +
    + +Possible solutions: +
      +
    • look out for async function calls in your function nodes that dont have error handling
    • +
    • check the issue tracker of the node that caused the crash
    • +
    • check the Node-RED issue tracker for similar issues
    • +

      + +{{#if log.html}} +

      +Logs... + + + + + + + {{#log.html}} + + + + + + {{/log.html}} +
      TimestampSeverityMessage
      {{{timestamp}}}{{{level}}}{{{message}}}
      +Note: Timestamps in this log are in UTC (Coordinated Universal Time). +

      +{{/if}} + +

      You can access the instance and its logs here

      +Instance Logs +` +} diff --git a/forge/postoffice/templates/Crashed.js b/forge/postoffice/templates/Crashed.js index d00cc1951c..a6cd66e3fd 100644 --- a/forge/postoffice/templates/Crashed.js +++ b/forge/postoffice/templates/Crashed.js @@ -3,18 +3,54 @@ module.exports = { text: `Hello -Your FlowFuse Instance "{{{ name }}}" has crashed. +Your FlowFuse Instance "{{{ name }}}"{{#if teamName.text}} in Team "{{{ teamName.text }}}"{{/if}} has crashed. -You can access the logs here: +{{#if log.text}} +------------------------------------------------------ +Logs... + +{{#log.text}} +Timestamp: {{{timestamp}}} +Severity: {{{level}}} +Message: {{{message}}} + +{{/log.text}} + +Note: Timestamps in this log are in UTC (Coordinated Universal Time). +------------------------------------------------------ +{{/if}} + +You can access the instance and its logs here: {{{ url }}} `, html: `

      Hello

      -

      Your FlowFuse Instance "{{{ name }}}" has crashed

      +

      Your FlowFuse Instance "{{{ name }}}"{{#if teamName.html}} in Team "{{{ teamName.html }}}"{{/if}} has crashed.

      + +{{#if log.html}} +

      +Logs... + + + + + + + {{#log.html}} + + + + + + {{/log.html}} +
      TimestampSeverityMessage
      {{{timestamp}}}{{{level}}}{{{message}}}
      +Note: Timestamps in this log are in UTC (Coordinated Universal Time). +

      +{{/if}} -

      You can access the logs here

      +

      You can access the instance and its logs here

      Instance Logs ` } From b232facba7abae4cbba00faae66c159fa3381fb6 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Wed, 18 Dec 2024 09:56:41 +0000 Subject: [PATCH 04/47] increase max-width of template for better log display --- forge/postoffice/layouts/default.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/forge/postoffice/layouts/default.js b/forge/postoffice/layouts/default.js index c3fcd702d4..9e0d7c49e5 100644 --- a/forge/postoffice/layouts/default.js +++ b/forge/postoffice/layouts/default.js @@ -22,7 +22,7 @@ module.exports = (htmlContent) => { style="border-collapse:collapse;font-family:Helvetica,Arial,sans-serif;font-size:15px;color:#33475b;word-break:break-word;padding-top:20px;padding-bottom:20px">
      -
      @@ -46,7 +46,7 @@ module.exports = (htmlContent) => {
      -
      {
      -
      {
      -
      +
      Date: Wed, 18 Dec 2024 09:57:07 +0000 Subject: [PATCH 05/47] ensure log is sanitised for html and for text --- forge/postoffice/index.js | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/forge/postoffice/index.js b/forge/postoffice/index.js index afbf98b3d0..df2eae8020 100644 --- a/forge/postoffice/index.js +++ b/forge/postoffice/index.js @@ -140,6 +140,50 @@ module.exports = fp(async function (app, _opts) { html: value.replace(/&/g, '&').replace(//g, '>').replace(/\./g, '
      .') } } + /** + * 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, '&').replace(//g, '>') + 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 @@ -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 }), From 4d543aa17d835d2a5df7b3e843911e35ac1f6dda Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Wed, 18 Dec 2024 10:10:23 +0000 Subject: [PATCH 06/47] remove duplicate text --- forge/postoffice/templates/Crashed-out-of-memory.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/forge/postoffice/templates/Crashed-out-of-memory.js b/forge/postoffice/templates/Crashed-out-of-memory.js index d1e216ba6c..dcc5e3cdc9 100644 --- a/forge/postoffice/templates/Crashed-out-of-memory.js +++ b/forge/postoffice/templates/Crashed-out-of-memory.js @@ -44,9 +44,9 @@ You can access the instance and its logs here:

      This can occur for a number of reasons including:

        -
      • incorrect instance size for your workload - try selecting a larger instance type
      • -
      • an issue in your flows holding onto memory - check your flows usage of context
      • -
      • an issue in a third-party library or node - check the issue tracker of your contrib nodes
      • +
      • incorrect instance size for your workload/li> +
      • an issue in your flows holding onto memory/li> +
      • an issue in a third-party library or node/li>
      Possible solutions: From c05b898b90c58338b605b4f1cd367077489f227b Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Wed, 18 Dec 2024 17:20:24 +0000 Subject: [PATCH 07/47] add tests to ensure correct template used and logs are rendered --- test/unit/forge/ee/lib/alerts/alerts_spec.js | 140 ++++++++++++++++++- 1 file changed, 139 insertions(+), 1 deletion(-) diff --git a/test/unit/forge/ee/lib/alerts/alerts_spec.js b/test/unit/forge/ee/lib/alerts/alerts_spec.js index 4da80533c9..847f2b9c07 100644 --- a/test/unit/forge/ee/lib/alerts/alerts_spec.js +++ b/test/unit/forge/ee/lib/alerts/alerts_spec.js @@ -1,4 +1,6 @@ const should = require('should') // eslint-disable-line +const sinon = require('sinon') + const setup = require('./setup') describe('Instance Alerts emails', function () { @@ -17,6 +19,7 @@ describe('Instance Alerts emails', function () { }) afterEach(async function () { inbox.messages = [] + sinon.restore() }) async function login (username, password) { @@ -35,12 +38,133 @@ describe('Instance Alerts emails', function () { await app.auditLog.alerts.generate(app.TestObjects.instance.id, 'crashed') inbox.messages.should.have.length(1) }) - it('Crashed, via api', async function () { + it('Crashed, via api (no logs)', async function () { + sinon.spy(app.postoffice, 'send') + const response = await app.inject({ + method: 'POST', + url: `/logging/${app.TestObjects.instance.id}/audit`, + payload: { + event: 'crashed', + error: { + code: 'crashed', + error: 'instance crashed' + } + }, + headers: { + authorization: `Bearer ${app.TestObjects.tokens.instance}` + } + }) + response.statusCode.should.equal(200) + inbox.messages.should.have.length(1) + // ensure correct template was used + app.postoffice.send.calledOnce.should.be.true() + app.postoffice.send.firstCall.args[1].should.equal('Crashed') + // ensure the logs section is not rendered + inbox.messages[0].html.should.not.match(/\n.*Logs\.\.\./) + }) + it('Crashed, generic, via api (including logs)', async function () { + sinon.spy(app.postoffice, 'send') + const response = await app.inject({ + method: 'POST', + url: `/logging/${app.TestObjects.instance.id}/audit`, + payload: { + event: 'crashed', + info: { + code: 1, + signal: null, + info: 'non zero exit code' + }, + __launcherLog: [ + { ts: 1647987742001, level: 'info', msg: 'info message 1' }, // 2022-03-22T22:22:22.001Z + { ts: 1647987742002, level: 'error', msg: 'error message 1' } // 2022-03-22T22:22:22.002Z + ], + error: { + code: 'crashed', + error: 'instance crashed' + } + }, + headers: { + authorization: `Bearer ${app.TestObjects.tokens.instance}` + } + }) + response.statusCode.should.equal(200) + + inbox.messages.should.have.length(1) + // use regex to ensure there is

      \n.*Logs... + inbox.messages[0].html.should.match(/

      \n.*Logs\.\.\./) + inbox.messages[0].html.should.match(/

      info message 1<\/td>/) + inbox.messages[0].html.should.match(/error message 1<\/td>/) + inbox.messages[0].text.should.match(/\n.*Logs\.\.\./) + inbox.messages[0].text.should.match(/\nTimestamp: 2022-03-22 22:22:22/) + inbox.messages[0].text.should.match(/\nSeverity: info/) + inbox.messages[0].text.should.match(/\nMessage: info message 1/) + inbox.messages[0].text.should.match(/\nTimestamp: 2022-03-22 22:22:22/) + inbox.messages[0].text.should.match(/\nSeverity: error/) + inbox.messages[0].text.should.match(/\nMessage: error message 1/) + // ensure correct template was used + app.postoffice.send.calledOnce.should.be.true() + app.postoffice.send.firstCall.args[1].should.equal('Crashed') + }) + it('Crashed, out-of-memory, via api (including logs)', async function () { + sinon.spy(app.postoffice, 'send') + const response = await app.inject({ + method: 'POST', + url: `/logging/${app.TestObjects.instance.id}/audit`, + payload: { + event: 'crashed', + info: { + code: 134, + signal: null, + info: 'non zero exit code' + }, + __launcherLog: [ + { ts: 1647987742001, level: 'info', msg: 'info message 1' }, // 2022-03-22T22:22:22.001Z + { ts: 1647987742002, level: 'error', msg: 'v8::internal::heap::' } // 2022-03-22T22:22:22.002Z + ], + error: { + code: 'crashed', + error: 'instance crashed' + } + }, + headers: { + authorization: `Bearer ${app.TestObjects.tokens.instance}` + } + }) + response.statusCode.should.equal(200) + + inbox.messages.should.have.length(1) + // use regex to ensure there is

      \n.*Logs... + inbox.messages[0].html.should.match(/

      \n.*Logs\.\.\./) + inbox.messages[0].html.should.match(/

      info message 1<\/td>/) + inbox.messages[0].html.should.match(/v8::internal::heap::<\/td>/) + inbox.messages[0].text.should.match(/\n.*Logs\.\.\./) + inbox.messages[0].text.should.match(/\nTimestamp: 2022-03-22 22:22:22/) + inbox.messages[0].text.should.match(/\nSeverity: info/) + inbox.messages[0].text.should.match(/\nMessage: info message 1/) + inbox.messages[0].text.should.match(/\nTimestamp: 2022-03-22 22:22:22/) + inbox.messages[0].text.should.match(/\nSeverity: error/) + inbox.messages[0].text.should.match(/\nMessage: v8::internal::heap::/) + + // ensure correct template was used + app.postoffice.send.calledOnce.should.be.true() + app.postoffice.send.firstCall.args[1].should.equal('Crashed-out-of-memory') + }) + it('Crashed, uncaught-exception, via api (including logs)', async function () { + sinon.spy(app.postoffice, 'send') const response = await app.inject({ method: 'POST', url: `/logging/${app.TestObjects.instance.id}/audit`, payload: { event: 'crashed', + info: { + code: 1, + signal: null, + info: 'non zero exit code' + }, + __launcherLog: [ + { ts: 1647987742001, level: 'info', msg: 'info message 1' }, // 2022-03-22T22:22:22.001Z + { ts: 1647987742002, level: 'error', msg: 'uncaught exception' } // 2022-03-22T22:22:22.002Z + ], error: { code: 'crashed', error: 'instance crashed' @@ -51,7 +175,21 @@ describe('Instance Alerts emails', function () { } }) response.statusCode.should.equal(200) + inbox.messages.should.have.length(1) + inbox.messages[0].html.should.match(/

      \n.*Logs\.\.\./) + inbox.messages[0].html.should.match(/

      info message 1<\/td>/) + inbox.messages[0].html.should.match(/uncaught exception<\/td>/) + inbox.messages[0].text.should.match(/\n.*Logs\.\.\./) + inbox.messages[0].text.should.match(/\nTimestamp: 2022-03-22 22:22:22/) + inbox.messages[0].text.should.match(/\nSeverity: info/) + inbox.messages[0].text.should.match(/\nMessage: info message 1/) + inbox.messages[0].text.should.match(/\nTimestamp: 2022-03-22 22:22:22/) + inbox.messages[0].text.should.match(/\nSeverity: error/) + inbox.messages[0].text.should.match(/\nMessage: uncaught exception/) + // ensure correct template was used + app.postoffice.send.calledOnce.should.be.true() + app.postoffice.send.firstCall.args[1].should.equal('Crashed-uncaught-exception') }) it('Owner notified of safe-mode', async function () { await app.auditLog.alerts.generate(app.TestObjects.instance.id, 'safe-mode') From 4e4d820c96aa4f520d5a4844f51916ec999db4bf Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Tue, 14 Jan 2025 10:03:10 +0000 Subject: [PATCH 08/47] Update forge/postoffice/templates/Crashed-out-of-memory.js Co-authored-by: Nick O'Leary --- forge/postoffice/templates/Crashed-out-of-memory.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge/postoffice/templates/Crashed-out-of-memory.js b/forge/postoffice/templates/Crashed-out-of-memory.js index dcc5e3cdc9..0f3813c7b0 100644 --- a/forge/postoffice/templates/Crashed-out-of-memory.js +++ b/forge/postoffice/templates/Crashed-out-of-memory.js @@ -19,7 +19,7 @@ Possible solutions: {{#if log.text}} ------------------------------------------------------ -Logs... +Logs: {{#log.text}} Timestamp: {{{timestamp}}} From cecf96cecd458d830413c8febfed13bbf6170752 Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Tue, 14 Jan 2025 10:03:16 +0000 Subject: [PATCH 09/47] Update forge/postoffice/templates/Crashed-out-of-memory.js Co-authored-by: Nick O'Leary --- forge/postoffice/templates/Crashed-out-of-memory.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge/postoffice/templates/Crashed-out-of-memory.js b/forge/postoffice/templates/Crashed-out-of-memory.js index 0f3813c7b0..ec6896100e 100644 --- a/forge/postoffice/templates/Crashed-out-of-memory.js +++ b/forge/postoffice/templates/Crashed-out-of-memory.js @@ -61,7 +61,7 @@ Possible solutions: {{#if log.html}}

      -Logs... +Logs: From e6f8ee64352d11ad283355481c2b8a0cf566ed94 Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Tue, 14 Jan 2025 10:03:24 +0000 Subject: [PATCH 10/47] Update forge/postoffice/templates/Crashed-uncaught-exception.js Co-authored-by: Nick O'Leary --- forge/postoffice/templates/Crashed-uncaught-exception.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge/postoffice/templates/Crashed-uncaught-exception.js b/forge/postoffice/templates/Crashed-uncaught-exception.js index ac26864265..2f928dd1a7 100644 --- a/forge/postoffice/templates/Crashed-uncaught-exception.js +++ b/forge/postoffice/templates/Crashed-uncaught-exception.js @@ -56,7 +56,7 @@ Possible solutions: {{#if log.html}}

      -Logs... +Logs:

      Timestamp
      From 27d1d8eeab696de51faa90eaf9540ebebf1de194 Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Tue, 14 Jan 2025 10:03:47 +0000 Subject: [PATCH 11/47] Update forge/postoffice/templates/Crashed-uncaught-exception.js Co-authored-by: Nick O'Leary --- forge/postoffice/templates/Crashed-uncaught-exception.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge/postoffice/templates/Crashed-uncaught-exception.js b/forge/postoffice/templates/Crashed-uncaught-exception.js index 2f928dd1a7..3535f128fc 100644 --- a/forge/postoffice/templates/Crashed-uncaught-exception.js +++ b/forge/postoffice/templates/Crashed-uncaught-exception.js @@ -17,7 +17,7 @@ Possible solutions: {{#if log.text}} ------------------------------------------------------ -Logs... +Logs: {{#log.text}} Timestamp: {{{timestamp}}} From e2edd21dc3b84baeeddd2c7838e2656ecd1fd2fd Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Tue, 14 Jan 2025 10:04:06 +0000 Subject: [PATCH 12/47] Update forge/postoffice/templates/Crashed.js Co-authored-by: Nick O'Leary --- forge/postoffice/templates/Crashed.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge/postoffice/templates/Crashed.js b/forge/postoffice/templates/Crashed.js index a6cd66e3fd..dfc7a1d23d 100644 --- a/forge/postoffice/templates/Crashed.js +++ b/forge/postoffice/templates/Crashed.js @@ -7,7 +7,7 @@ Your FlowFuse Instance "{{{ name }}}"{{#if teamName.text}} in Team "{{{ teamName {{#if log.text}} ------------------------------------------------------ -Logs... +Logs: {{#log.text}} Timestamp: {{{timestamp}}} From 578111f2a76acee0d955eca5ef432f448352058f Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Tue, 14 Jan 2025 10:04:20 +0000 Subject: [PATCH 13/47] Update forge/postoffice/templates/Crashed.js Co-authored-by: Nick O'Leary --- forge/postoffice/templates/Crashed.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge/postoffice/templates/Crashed.js b/forge/postoffice/templates/Crashed.js index dfc7a1d23d..5487916afd 100644 --- a/forge/postoffice/templates/Crashed.js +++ b/forge/postoffice/templates/Crashed.js @@ -31,7 +31,7 @@ You can access the instance and its logs here: {{#if log.html}}

      -Logs... +Logs:

      Timestamp
      From 4b4563c4eedbe53e4f45ad5f560f70e253b1b8af Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Tue, 14 Jan 2025 10:04:32 +0000 Subject: [PATCH 14/47] Update test/unit/forge/ee/lib/alerts/alerts_spec.js Co-authored-by: Nick O'Leary --- test/unit/forge/ee/lib/alerts/alerts_spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/forge/ee/lib/alerts/alerts_spec.js b/test/unit/forge/ee/lib/alerts/alerts_spec.js index 847f2b9c07..869831819d 100644 --- a/test/unit/forge/ee/lib/alerts/alerts_spec.js +++ b/test/unit/forge/ee/lib/alerts/alerts_spec.js @@ -90,11 +90,11 @@ describe('Instance Alerts emails', function () { response.statusCode.should.equal(200) inbox.messages.should.have.length(1) - // use regex to ensure there is

      \n.*Logs... - inbox.messages[0].html.should.match(/

      \n.*Logs\.\.\./) + // use regex to ensure there is

      \n.*Logs: + inbox.messages[0].html.should.match(/

      \n.*Logs:/) inbox.messages[0].html.should.match(/

      Timestampinfo message 1<\/td>/) inbox.messages[0].html.should.match(/error message 1<\/td>/) - inbox.messages[0].text.should.match(/\n.*Logs\.\.\./) + inbox.messages[0].text.should.match(/\n.*Logs:/) inbox.messages[0].text.should.match(/\nTimestamp: 2022-03-22 22:22:22/) inbox.messages[0].text.should.match(/\nSeverity: info/) inbox.messages[0].text.should.match(/\nMessage: info message 1/) From 028db1356ef871fca509d1b30428343a02ccecc0 Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Tue, 14 Jan 2025 10:04:39 +0000 Subject: [PATCH 15/47] Update test/unit/forge/ee/lib/alerts/alerts_spec.js Co-authored-by: Nick O'Leary --- test/unit/forge/ee/lib/alerts/alerts_spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/forge/ee/lib/alerts/alerts_spec.js b/test/unit/forge/ee/lib/alerts/alerts_spec.js index 869831819d..0c5579207c 100644 --- a/test/unit/forge/ee/lib/alerts/alerts_spec.js +++ b/test/unit/forge/ee/lib/alerts/alerts_spec.js @@ -133,11 +133,11 @@ describe('Instance Alerts emails', function () { response.statusCode.should.equal(200) inbox.messages.should.have.length(1) - // use regex to ensure there is

      \n.*Logs... - inbox.messages[0].html.should.match(/

      \n.*Logs\.\.\./) + // use regex to ensure there is

      \n.*Logs: + inbox.messages[0].html.should.match(/

      \n.*Logs:/) inbox.messages[0].html.should.match(/

      info message 1<\/td>/) inbox.messages[0].html.should.match(/v8::internal::heap::<\/td>/) - inbox.messages[0].text.should.match(/\n.*Logs\.\.\./) + inbox.messages[0].text.should.match(/\n.*Logs:/) inbox.messages[0].text.should.match(/\nTimestamp: 2022-03-22 22:22:22/) inbox.messages[0].text.should.match(/\nSeverity: info/) inbox.messages[0].text.should.match(/\nMessage: info message 1/) From 74e8b8d1d7f12b5f6816cb9c7d5aeda3f1b6de84 Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Tue, 14 Jan 2025 10:04:46 +0000 Subject: [PATCH 16/47] Update test/unit/forge/ee/lib/alerts/alerts_spec.js Co-authored-by: Nick O'Leary --- test/unit/forge/ee/lib/alerts/alerts_spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/forge/ee/lib/alerts/alerts_spec.js b/test/unit/forge/ee/lib/alerts/alerts_spec.js index 0c5579207c..4bb5ee0bc5 100644 --- a/test/unit/forge/ee/lib/alerts/alerts_spec.js +++ b/test/unit/forge/ee/lib/alerts/alerts_spec.js @@ -177,10 +177,10 @@ describe('Instance Alerts emails', function () { response.statusCode.should.equal(200) inbox.messages.should.have.length(1) - inbox.messages[0].html.should.match(/

      \n.*Logs\.\.\./) + inbox.messages[0].html.should.match(/

      \n.*Logs:/) inbox.messages[0].html.should.match(/

      info message 1<\/td>/) inbox.messages[0].html.should.match(/uncaught exception<\/td>/) - inbox.messages[0].text.should.match(/\n.*Logs\.\.\./) + inbox.messages[0].text.should.match(/\n.*Logs:/) inbox.messages[0].text.should.match(/\nTimestamp: 2022-03-22 22:22:22/) inbox.messages[0].text.should.match(/\nSeverity: info/) inbox.messages[0].text.should.match(/\nMessage: info message 1/) From e78e25e52139f5d4f660129e4b13b3bec55ee5a4 Mon Sep 17 00:00:00 2001 From: ppawlowski Date: Tue, 14 Jan 2025 11:32:00 +0100 Subject: [PATCH 17/47] Enable concurrency in `Tests` workflow --- .github/workflows/tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 285cacd852..0102d5bd92 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,6 +7,10 @@ on: - maintenance pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: check-changes: name: Verify changes From 479c08d814e530724add85c5e2ca697739f085e9 Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 14 Jan 2025 14:18:46 +0200 Subject: [PATCH 18/47] register a new team device-groups endpoint and add a basic endpoint boilerplate --- forge/ee/routes/index.js | 1 + forge/ee/routes/teamDeviceGroups/index.js | 116 ++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 forge/ee/routes/teamDeviceGroups/index.js diff --git a/forge/ee/routes/index.js b/forge/ee/routes/index.js index b7d6bc91b7..03c3d6a10b 100644 --- a/forge/ee/routes/index.js +++ b/forge/ee/routes/index.js @@ -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 }) diff --git a/forge/ee/routes/teamDeviceGroups/index.js b/forge/ee/routes/teamDeviceGroups/index.js new file mode 100644 index 0000000000..5d6e841224 --- /dev/null +++ b/forge/ee/routes/teamDeviceGroups/index.js @@ -0,0 +1,116 @@ +/** + * Team DeviceGroup api routes + * + * - /api/v1/teams/:teamId/device-groups + * + * @namespace teams + * @memberof forge.routes.api + */ + +// const { ValidationError } = require('sequelize') +// +// const { UpdatesCollection } = require('../../../auditLog/formatters.js') +// const { Roles } = require('../../../lib/roles.js') +// const { DeviceGroupMembershipValidationError } = require('../../db/controllers/DeviceGroup.js') +// +// // Declare getLogger function to provide type hints / quick code nav / code completion +// /** @type {import('../../../../forge/auditLog/application').getLoggers} */ +// const getApplicationLogger = (app) => { return app.auditLog.Application } + +/** + * @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) => { + // todo validation preHandler + // // Get the application + // const applicationId = request.params.applicationId + // 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' }) + // } + // } + // + // const teamType = await request.application.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 the device group + // const groupId = request.params.groupId + // if (groupId) { + // request.deviceGroup = await app.db.models.DeviceGroup.byId(groupId) + // if (!request.deviceGroup || request.deviceGroup.ApplicationId !== request.application.id) { + // reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + // } + // } + }) + + /** + * Get a list of team device groups + * @method GET + * @name /api/v1/teams/:teamId/device-groups + * @memberof forge.routes.api.application + */ + app.get('/', { + preHandler: app.needsPermission('application: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) => { + reply.send({ + meta: {}, + count: 0, + groups: [] + }) + // const paginationOptions = app.db.controllers.Device.getDevicePaginationOptions(request) + // + // const where = { + // ApplicationId: request.application.hashid + // } + // + // const groupData = await app.db.models.DeviceGroup.getAll(paginationOptions, where) + // const result = { + // count: groupData.count, + // meta: groupData.meta, + // groups: (groupData.groups || []).map(d => app.db.views.DeviceGroup.deviceGroupSummary(d, { includeApplication: false })) + // } + // reply.send(result) + }) +} From 92c47a436faacf56b93502bde1c336074c50e255 Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 14 Jan 2025 16:51:48 +0200 Subject: [PATCH 19/47] extend the deviceGroupModel getAll method to query deviceGroups of multiple applications at once and also the ability to include the device group's application in the returned response --- forge/db/models/DeviceGroup.js | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/forge/db/models/DeviceGroup.js b/forge/db/models/DeviceGroup.js index 5a2951944a..7627130683 100644 --- a/forge/db/models/DeviceGroup.js +++ b/forge/db/models/DeviceGroup.js @@ -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']), @@ -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: [ From 079620670dbbd667c1d7e4d6e5b33cdb5b422f1e Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 14 Jan 2025 16:52:26 +0200 Subject: [PATCH 20/47] extend the deviceGroup schema to include application summary --- forge/db/views/DeviceGroup.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/forge/db/views/DeviceGroup.js b/forge/db/views/DeviceGroup.js index f88ae3b624..b0215df3af 100644 --- a/forge/db/views/DeviceGroup.js +++ b/forge/db/views/DeviceGroup.js @@ -10,13 +10,20 @@ 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, @@ -24,6 +31,11 @@ module.exports = function (app) { 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 } From 874a98214753e803fbee540f2f4906bed6ef5520 Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 14 Jan 2025 16:53:16 +0200 Subject: [PATCH 21/47] add a new team device-group list permission --- forge/lib/permissions.js | 1 + 1 file changed, 1 insertion(+) diff --git a/forge/lib/permissions.js b/forge/lib/permissions.js index 6e9ff5b9ea..2cfdd39348 100644 --- a/forge/lib/permissions.js +++ b/forge/lib/permissions.js @@ -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 }, From d9eb0d13b37bf8016af8e6ad9149fd4b53155036 Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 14 Jan 2025 16:53:50 +0200 Subject: [PATCH 22/47] touch up the team device group endpoint --- forge/ee/routes/teamDeviceGroups/index.js | 110 +++++++++------------- 1 file changed, 45 insertions(+), 65 deletions(-) diff --git a/forge/ee/routes/teamDeviceGroups/index.js b/forge/ee/routes/teamDeviceGroups/index.js index 5d6e841224..13be20e09e 100644 --- a/forge/ee/routes/teamDeviceGroups/index.js +++ b/forge/ee/routes/teamDeviceGroups/index.js @@ -7,16 +7,6 @@ * @memberof forge.routes.api */ -// const { ValidationError } = require('sequelize') -// -// const { UpdatesCollection } = require('../../../auditLog/formatters.js') -// const { Roles } = require('../../../lib/roles.js') -// const { DeviceGroupMembershipValidationError } = require('../../db/controllers/DeviceGroup.js') -// -// // Declare getLogger function to provide type hints / quick code nav / code completion -// /** @type {import('../../../../forge/auditLog/application').getLoggers} */ -// const getApplicationLogger = (app) => { return app.auditLog.Application } - /** * @param {import('../../../forge.js').ForgeApplication} app The application instance */ @@ -24,51 +14,43 @@ module.exports = async function (app) { // pre-handler for all routes in this file app.addHook('preHandler', async (request, reply) => { // todo validation preHandler - // // Get the application - // const applicationId = request.params.applicationId - // 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' }) - // } - // } - // - // const teamType = await request.application.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 the device group - // const groupId = request.params.groupId - // if (groupId) { - // request.deviceGroup = await app.db.models.DeviceGroup.byId(groupId) - // if (!request.deviceGroup || request.deviceGroup.ApplicationId !== request.application.id) { - // reply.code(404).send({ code: 'not_found', error: 'Not Found' }) - // } - // } + + // 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.bySlug(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.params.teamId) + 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.application + * @memberof forge.routes.api.team */ app.get('/', { - preHandler: app.needsPermission('application:device-group:list'), + preHandler: app.needsPermission('team:device-group:list'), schema: { summary: 'Get a list of device groups in an application', tags: ['Application Device Groups'], @@ -94,23 +76,21 @@ module.exports = async function (app) { } } }, async (request, reply) => { - reply.send({ - meta: {}, - count: 0, - groups: [] - }) - // const paginationOptions = app.db.controllers.Device.getDevicePaginationOptions(request) - // - // const where = { - // ApplicationId: request.application.hashid - // } - // - // const groupData = await app.db.models.DeviceGroup.getAll(paginationOptions, where) - // const result = { - // count: groupData.count, - // meta: groupData.meta, - // groups: (groupData.groups || []).map(d => app.db.views.DeviceGroup.deviceGroupSummary(d, { includeApplication: false })) - // } - // reply.send(result) + 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) }) } From f8f3f9079f386ee1a72dcc581791d86c3fcdc27c Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Tue, 14 Jan 2025 15:08:41 +0000 Subject: [PATCH 23/47] Add Open Schema button to Topic Hierarcy view --- forge/ee/routes/teamBroker/schema.js | 5 ++++- frontend/src/pages/team/UNS/Hierarchy/index.vue | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/forge/ee/routes/teamBroker/schema.js b/forge/ee/routes/teamBroker/schema.js index d2bd59f21e..d3fd05369b 100644 --- a/forge/ee/routes/teamBroker/schema.js +++ b/forge/ee/routes/teamBroker/schema.js @@ -24,7 +24,10 @@ module.exports = async function (app) { schema.servers = { 'team-broker': { host: teamBrokerHost, - protocol: 'mqtt' + protocol: 'mqtt', + security: [{ + type: 'userPassword' + }] } } } diff --git a/frontend/src/pages/team/UNS/Hierarchy/index.vue b/frontend/src/pages/team/UNS/Hierarchy/index.vue index 8b71b6d23e..3318dae578 100644 --- a/frontend/src/pages/team/UNS/Hierarchy/index.vue +++ b/frontend/src/pages/team/UNS/Hierarchy/index.vue @@ -2,7 +2,11 @@
      tree-icon -

      Topic Hierarchy

      +

      Topic Hierarchy

      + + + Open Schema +
      +import { ExternalLinkIcon } from '@heroicons/vue/solid' import { mapGetters, mapState } from 'vuex' import brokerClient from '../../../../api/broker.js' import EmptyState from '../../../../components/EmptyState.vue' +import { useNavigationHelper } from '../../../../composables/NavigationHelper.js' + import TopicSegment from './components/TopicSegment.vue' +const { openInANewTab } = useNavigationHelper() + export default { name: 'UNSHierarchy', - components: { TopicSegment, EmptyState }, + components: { TopicSegment, EmptyState, ExternalLinkIcon }, data () { return { loading: false, @@ -179,6 +188,9 @@ export default { toggleSegmentVisibility (segment) { // trigger's the hierarchy setter this.hierarchy = segment + }, + openSchema () { + openInANewTab(`/api/v1/teams/${this.team.id}/broker/team-broker/schema.yml`, '_blank') } } } From 3833d6c5913fe66a36252bdfafbde5a116f63406 Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 14 Jan 2025 17:27:31 +0200 Subject: [PATCH 24/47] Use named routes for the application summary labels --- .../pages/team/components/ApplicationSummaryLabel.vue | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/team/components/ApplicationSummaryLabel.vue b/frontend/src/pages/team/components/ApplicationSummaryLabel.vue index 3e4de5ed8f..0e941f071d 100644 --- a/frontend/src/pages/team/components/ApplicationSummaryLabel.vue +++ b/frontend/src/pages/team/components/ApplicationSummaryLabel.vue @@ -2,7 +2,7 @@
      Date: Tue, 14 Jan 2025 18:09:25 +0200 Subject: [PATCH 25/47] Set pending state when loggin in to prevent no team limbo --- frontend/src/store/account.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/store/account.js b/frontend/src/store/account.js index 6ab531ce18..74f316b89c 100644 --- a/frontend/src/store/account.js +++ b/frontend/src/store/account.js @@ -205,6 +205,9 @@ const mutations = { clearPending (state) { state.pending = false }, + setPending (state, pending) { + state.pending = pending + }, setLoginInflight (state) { state.loginInflight = true }, @@ -397,6 +400,7 @@ const actions = { } else if (credentials.token) { await userApi.verifyMFAToken(credentials.token) } + state.commit('setPending', true) state.dispatch('checkState', state.getters.redirectUrlAfterLogin) } catch (err) { if (err.response?.status >= 401) { From 36415e9c04ca288d60f467cf3f8e412a7c359b17 Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 15 Jan 2025 14:34:45 +0200 Subject: [PATCH 26/47] adding unit tests coverage --- forge/ee/routes/teamDeviceGroups/index.js | 4 +- .../ee/routes/api/team-device-groups.spec.js | 297 ++++++++++++++++++ 2 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 test/unit/forge/ee/routes/api/team-device-groups.spec.js diff --git a/forge/ee/routes/teamDeviceGroups/index.js b/forge/ee/routes/teamDeviceGroups/index.js index 13be20e09e..b910875df5 100644 --- a/forge/ee/routes/teamDeviceGroups/index.js +++ b/forge/ee/routes/teamDeviceGroups/index.js @@ -13,8 +13,6 @@ module.exports = async function (app) { // pre-handler for all routes in this file app.addHook('preHandler', async (request, reply) => { - // todo validation preHandler - // Get the team const teamId = request.params.teamId if (!teamId) { @@ -28,7 +26,7 @@ module.exports = async function (app) { } if (request.session.User) { - request.teamMembership = await request.session.User.getTeamMembership(request.params.teamId) + 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' }) } diff --git a/test/unit/forge/ee/routes/api/team-device-groups.spec.js b/test/unit/forge/ee/routes/api/team-device-groups.spec.js new file mode 100644 index 0000000000..a6f0897168 --- /dev/null +++ b/test/unit/forge/ee/routes/api/team-device-groups.spec.js @@ -0,0 +1,297 @@ +require('should') // eslint-disable-line +require('sinon') + +const setup = require('../../setup.js') + +const FF_UTIL = require('flowforge-test-utils') +const { Roles } = FF_UTIL.require('forge/lib/roles') + +describe('Team Device Groups API', function () { + let app + /** @type {import('../../../../../lib/TestModelFactory')} */ + let factory = null + let objectCount = 0 + const generateName = (root = 'object') => `${root}-${objectCount++}` + + const TestObjects = { + /** admin - owns ateam */ + alice: {}, + /** owner of bteam */ + bob: {}, + /** member of b team */ + chris: {}, + /** not connected to any teams */ + dave: {}, + ATeam: {}, + BTeam: {}, + CTeam: {}, + /** B-team Application */ + application: {}, + /** B-team Instance */ + instance: {} + } + + async function login (username, password) { + const response = await app.inject({ + method: 'POST', + url: '/account/login', + payload: { username, password, remember: false } + }) + response.cookies.should.have.length(1) + response.cookies[0].should.have.property('name', 'sid') + return response.cookies[0].value + } + + before(async function () { + app = await setup() + factory = app.factory + + // Alice create in setup() + TestObjects.alice = await app.db.models.User.byUsername('alice') + TestObjects.bob = await app.db.models.User.create({ username: 'bob', name: 'Bob Solo', email: 'bob@example.com', email_verified: true, password: 'bbPassword' }) + TestObjects.chris = await app.db.models.User.create({ username: 'chris', name: 'Chris Crackers', email: 'chris@example.com', email_verified: true, password: 'ccPassword' }) + TestObjects.dave = await app.db.models.User.create({ username: 'dave', name: 'Dave Smith', email: 'dave@example.com', email_verified: true, password: 'ddPassword' }) + + // ATeam create in setup() + TestObjects.ATeam = await app.db.models.Team.byName('ATeam') + + // Need to give the default TeamType permission to use DeviceGroup feature + const defaultTeamTypeProperties = app.defaultTeamType.properties + defaultTeamTypeProperties.features.deviceGroups = true + app.defaultTeamType.properties = defaultTeamTypeProperties + await app.defaultTeamType.save() + + TestObjects.BTeam = await app.db.models.Team.create({ name: 'BTeam', TeamTypeId: app.defaultTeamType.id }) + TestObjects.CTeam = await app.db.models.Team.create({ name: 'CTeam', TeamTypeId: app.defaultTeamType.id }) + + // alice : admin - owns ateam (setup) + // bob - owner of bteam + // chris - member of b team + // dave - not connected to any teams + await TestObjects.BTeam.addUser(TestObjects.bob, { through: { role: Roles.Owner } }) + await TestObjects.BTeam.addUser(TestObjects.chris, { through: { role: Roles.Member } }) + + await TestObjects.CTeam.addUser(TestObjects.chris, { through: { role: Roles.Owner } }) + await TestObjects.CTeam.addUser(TestObjects.dave, { through: { role: Roles.Viewer } }) + await TestObjects.CTeam.addUser(TestObjects.bob, { through: { role: Roles.Member } }) + + TestObjects.application = await app.db.models.Application.create({ + name: 'B-team Application', + description: 'B-team Application description', + TeamId: TestObjects.BTeam.id + }) + TestObjects.instance = await app.factory.createInstance({ name: 'B-team-instance' }, TestObjects.application, app.stack, app.template, app.projectType, { start: false }) + }) + + describe('the preHandler', () => { + it('should return a 404 not found response when no team is provided', async () => { + const sid = await login('bob', 'bbPassword') + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/teams//device-groups', + cookies: { sid } + }) + + response.statusCode.should.equal(404) + }) + + it('should return a 404 not found response when the provided team is not found', async () => { + const sid = await login('bob', 'bbPassword') + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/teams/noteam/device-groups', + cookies: { sid } + }) + + response.statusCode.should.equal(404) + }) + + it('should return a 404 response when the user is not part of the requested team', async () => { + const sid = await login('bob', 'bbPassword') + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.ATeam.slug}/device-groups`, + cookies: { sid } + }) + + response.statusCode.should.equal(404) + }) + + it('should not return a 404 response when the user is part of the requested team', async () => { + const sid = await login('bob', 'bbPassword') + const response = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.BTeam.slug}/device-groups`, + cookies: { sid } + }) + response.statusCode.should.equal(200) + }) + + it('should not return a 404 response when the user is an admin', async () => { + const sid = await login('alice', 'aaPassword') + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.BTeam.slug}/device-groups`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + }) + }) + + describe('Listing Team Device Groups', () => { + it('should return a single application\'s device groups', async () => { + const sid = await login('bob', 'bbPassword') + const application = await factory.createApplication({ name: generateName('app') }, TestObjects.BTeam) + + const deviceGroup1 = await factory.createApplicationDeviceGroup({ name: 'dev-group-1' }, application) + const deviceGroup2 = await factory.createApplicationDeviceGroup({ name: 'dev-group-2' }, application) + + const device1 = await factory.createDevice({ name: 'dev1' }, TestObjects.BTeam, null, application) + await factory.addDeviceToGroup(device1, deviceGroup1) + + const device2 = await factory.createDevice({ name: 'dev2' }, TestObjects.BTeam, null, application) + await factory.addDeviceToGroup(device2, deviceGroup2) + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.BTeam.slug}/device-groups`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + + const result = response.json() + + result.count.should.equal(2) + result.groups[0].should.have.property('name', 'dev-group-1') + result.groups[1].should.have.property('name', 'dev-group-2') + }) + + it('should return device groups from multiple applications belonging to the same team', async () => { + const sid = await login('alice', 'aaPassword') + const application1 = await factory.createApplication({ name: generateName('app') }, TestObjects.ATeam) + const application2 = await factory.createApplication({ name: generateName('another-app') }, TestObjects.ATeam) + + const deviceGroup1 = await factory.createApplicationDeviceGroup({ name: 'dev-group-1' }, application1) + const deviceGroup2 = await factory.createApplicationDeviceGroup({ name: 'dev-group-2' }, application1) + const deviceGroup3 = await factory.createApplicationDeviceGroup({ name: 'dev-group-3' }, application2) + const deviceGroup4 = await factory.createApplicationDeviceGroup({ name: 'dev-group-4' }, application2) + + const device1 = await factory.createDevice({ name: 'dev1' }, TestObjects.BTeam, null, application1) + await factory.addDeviceToGroup(device1, deviceGroup1) + + const device2 = await factory.createDevice({ name: 'dev2' }, TestObjects.BTeam, null, application1) + await factory.addDeviceToGroup(device2, deviceGroup2) + + const device3 = await factory.createDevice({ name: 'dev1' }, TestObjects.BTeam, null, application2) + await factory.addDeviceToGroup(device3, deviceGroup3) + + const device4 = await factory.createDevice({ name: 'dev2' }, TestObjects.BTeam, null, application2) + await factory.addDeviceToGroup(device4, deviceGroup4) + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.ATeam.slug}/device-groups`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + + const result = response.json() + + result.count.should.equal(4) + result.groups[0].should.have.property('name', 'dev-group-1') + result.groups[1].should.have.property('name', 'dev-group-2') + result.groups[2].should.have.property('name', 'dev-group-3') + result.groups[3].should.have.property('name', 'dev-group-4') + }) + + it('should not return device groups belonging to other teams', async () => { + const sid = await login('chris', 'ccPassword') + + const aTeamApplication = await factory.createApplication({ name: generateName('app') }, TestObjects.ATeam) + const bTeamApplication = await factory.createApplication({ name: generateName('app1') }, TestObjects.BTeam) + const cTeamApplication = await factory.createApplication({ name: generateName('app') }, TestObjects.CTeam) + + const aTeamDeviceGroup = await factory.createApplicationDeviceGroup({ name: 'aTeam-dev-group' }, aTeamApplication) + const bTeamDeviceGroup = await factory.createApplicationDeviceGroup({ name: 'bTeam-dev-group' }, bTeamApplication) + const cTeamDeviceGroup = await factory.createApplicationDeviceGroup({ name: 'cTeam-dev-group' }, cTeamApplication) + + const aTeamDevice = await factory.createDevice({ name: 'dev1' }, TestObjects.BTeam, null, aTeamApplication) + await factory.addDeviceToGroup(aTeamDevice, aTeamDeviceGroup) + + const bTeamDevice = await factory.createDevice({ name: 'dev2' }, TestObjects.BTeam, null, bTeamApplication) + await factory.addDeviceToGroup(bTeamDevice, bTeamDeviceGroup) + + const cTeamDevice = await factory.createDevice({ name: 'dev3' }, TestObjects.CTeam, null, cTeamApplication) + await factory.addDeviceToGroup(cTeamDevice, cTeamDeviceGroup) + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.CTeam.slug}/device-groups`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + + const result = response.json() + + result.count.should.equal(1) + result.groups[0].should.have.property('name', 'cTeam-dev-group') + result.groups.should.have.length(1) + }) + + it('should return a 200 response to admin roles', async () => { + const sid = await login('alice', 'aaPassword') + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.CTeam.slug}/device-groups`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + }) + + it('should return a 200 response to owner roles', async () => { + const sid = await login('chris', 'ccPassword') + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.CTeam.slug}/device-groups`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + }) + + it('should return a 200 response to member roles', async () => { + const sid = await login('bob', 'bbPassword') + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.CTeam.slug}/device-groups`, + cookies: { sid } + }) + + response.statusCode.should.equal(200) + }) + + it('should return an unauthorized response for viewer roles', async () => { + const sid = await login('dave', 'ddPassword') + + const response = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${TestObjects.CTeam.slug}/device-groups`, + cookies: { sid } + }) + + response.statusCode.should.equal(403) + }) + }) +}) From f78f8109e466ab3c4a55e9e54f9c47a350a9af29 Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 15 Jan 2025 14:57:18 +0200 Subject: [PATCH 27/47] remove sinon and save should in a const --- test/unit/forge/ee/routes/api/team-device-groups.spec.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/unit/forge/ee/routes/api/team-device-groups.spec.js b/test/unit/forge/ee/routes/api/team-device-groups.spec.js index a6f0897168..cc670e807f 100644 --- a/test/unit/forge/ee/routes/api/team-device-groups.spec.js +++ b/test/unit/forge/ee/routes/api/team-device-groups.spec.js @@ -1,5 +1,4 @@ -require('should') // eslint-disable-line -require('sinon') +const should = require('should') // eslint-disable-line const setup = require('../../setup.js') From 769c9d5dedd591529481eaf552266304935db3cc Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 15 Jan 2025 15:20:26 +0200 Subject: [PATCH 28/47] qf test name --- .../{team-device-groups.spec.js => team-device-groups_spec.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/unit/forge/ee/routes/api/{team-device-groups.spec.js => team-device-groups_spec.js} (100%) diff --git a/test/unit/forge/ee/routes/api/team-device-groups.spec.js b/test/unit/forge/ee/routes/api/team-device-groups_spec.js similarity index 100% rename from test/unit/forge/ee/routes/api/team-device-groups.spec.js rename to test/unit/forge/ee/routes/api/team-device-groups_spec.js From 60a16593bf4406415e0704c1e9e8bcc58fb768ad Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 15 Jan 2025 15:22:05 +0200 Subject: [PATCH 29/47] qf test name --- .../{team-device-groups.spec.js => team-device-groups_spec.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/unit/forge/ee/routes/api/{team-device-groups.spec.js => team-device-groups_spec.js} (100%) diff --git a/test/unit/forge/ee/routes/api/team-device-groups.spec.js b/test/unit/forge/ee/routes/api/team-device-groups_spec.js similarity index 100% rename from test/unit/forge/ee/routes/api/team-device-groups.spec.js rename to test/unit/forge/ee/routes/api/team-device-groups_spec.js From 79a6bbbc6311c7c4c880d78fdf54f38439794825 Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 15 Jan 2025 16:26:39 +0200 Subject: [PATCH 30/47] team device groups ui scaffolding --- .../components/icons/DeviceGroupOutline.js | 35 ++++ .../src/pages/team/DeviceGroups/index.vue | 163 ++++++++++++++++++ frontend/src/pages/team/routes.js | 9 + frontend/src/store/account.js | 3 +- frontend/src/store/ux.js | 10 ++ 5 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/icons/DeviceGroupOutline.js create mode 100644 frontend/src/pages/team/DeviceGroups/index.vue diff --git a/frontend/src/components/icons/DeviceGroupOutline.js b/frontend/src/components/icons/DeviceGroupOutline.js new file mode 100644 index 0000000000..a8c2a9115d --- /dev/null +++ b/frontend/src/components/icons/DeviceGroupOutline.js @@ -0,0 +1,35 @@ +const { createVNode: _createVNode, openBlock: _openBlock, createBlock: _createBlock } = require('vue') + +module.exports = function render (_ctx, _cache) { + return ( + _openBlock(), + _createBlock( + 'svg', + { + width: 16, + height: 16, + viewBox: '0 0 16 16', + fill: 'currentColor', + xmlns: 'http://www.w3.org/2000/svg' + }, + [ + _createVNode('path', { + d: 'M11.5122 7.47229H7.45898V11.5255H11.5122V7.47229Z', + fill: 'currentFill' + }), + _createVNode('path', { + 'fill-rule': 'evenodd', + 'clip-rule': 'evenodd', + d: 'M4.48778 11.5078V13.2373C4.48778 13.5654 4.62082 13.8936 4.86029 14.1331C5.09976 14.3725 5.41905 14.5056 5.75608 14.5056H7.48557V15.49C7.48557 15.6231 7.53878 15.7561 7.63634 15.8537C7.83146 16.0488 8.16849 16.0488 8.36361 15.8537C8.46117 15.7561 8.51439 15.6319 8.51439 15.49V14.5056H10.4745V15.49C10.4745 15.6231 10.5277 15.7561 10.6253 15.8537C10.8204 16.0488 11.1574 16.0488 11.3525 15.8537C11.4501 15.7561 11.5033 15.6319 11.5033 15.49V14.5056H13.2328C13.561 14.5056 13.8891 14.3725 14.1286 14.1331C14.368 13.8936 14.5011 13.5743 14.5011 13.2373V11.5078H15.4856C15.6186 11.5078 15.7516 11.4546 15.8492 11.357C15.9468 11.2594 16 11.1264 16 10.9934C16 10.8603 15.9468 10.7273 15.8492 10.6297C15.7516 10.5322 15.6275 10.479 15.4856 10.479H14.5011V8.51887H15.4856C15.6186 8.51887 15.7516 8.46565 15.8492 8.36809C15.9468 8.27053 16 8.13749 16 8.00445C16 7.87142 15.9468 7.73838 15.8492 7.64082C15.7516 7.54326 15.6186 7.49004 15.4856 7.49004H14.5011V5.76055C14.5011 5.43239 14.368 5.10423 14.1286 4.86476C13.8891 4.6253 13.5698 4.49226 13.2328 4.49226H8.51439V3.50778C8.51439 3.37474 8.46117 3.24171 8.36361 3.14414C8.16849 2.94902 7.83146 2.94902 7.63634 3.14414C7.53878 3.24171 7.48557 3.36587 7.48557 3.50778V4.49226H5.75608C5.41905 4.49226 5.09976 4.6253 4.86029 4.86476C4.62969 5.09536 4.48778 5.42352 4.48778 5.76055V7.49004H3.5033C3.37027 7.49004 3.23723 7.54326 3.13967 7.64082C3.04211 7.73838 2.98889 7.87142 2.98889 8.00445C2.98889 8.13749 3.04211 8.27053 3.13967 8.36809C3.23723 8.46565 3.37027 8.51887 3.5033 8.51887H4.48778V11.5078ZM13.4634 13.4679H5.51661V5.52108H13.4634V13.4679Z', + fill: 'currentFill' + }), + _createVNode('path', { + 'fill-rule': 'evenodd', + 'clip-rule': 'evenodd', + d: 'M4.49667 10.4789H2.53659V2.53215H10.4834V4.49224H11.5122V2.76275C11.5122 2.43459 11.3792 2.10643 11.1397 1.86696C10.9091 1.63636 10.5809 1.49446 10.2439 1.49446H8.51441V0.509978C8.51441 0.37694 8.4612 0.243902 8.36364 0.146341C8.16851 -0.0487805 7.83148 -0.0487805 7.63636 0.146341C7.5388 0.243902 7.48559 0.368071 7.48559 0.509978V1.49446H5.5255V0.509978C5.5255 0.37694 5.47228 0.243902 5.37472 0.146341C5.1796 -0.0487805 4.84257 -0.0487805 4.64745 0.146341C4.54989 0.243902 4.49667 0.368071 4.49667 0.509978V1.49446H2.76718C2.43016 1.49446 2.11086 1.62749 1.8714 1.86696C1.6408 2.09756 1.49889 2.42572 1.49889 2.76275V4.49224H0.514412C0.381375 4.49224 0.248337 4.54545 0.150776 4.64301C0.0532151 4.74058 0 4.87361 0 5.00665C0 5.13969 0.0532151 5.27273 0.150776 5.37029C0.248337 5.46785 0.381375 5.52106 0.514412 5.52106H1.49889V7.48115H0.514412C0.381375 7.48115 0.248337 7.53437 0.150776 7.63193C0.0532151 7.72949 0 7.85366 0 7.99556C0 8.13747 0.0532151 8.26164 0.150776 8.3592C0.248337 8.45676 0.381375 8.50998 0.514412 8.50998H1.49889V10.2395C1.49889 10.5676 1.63193 10.8958 1.8714 11.1353C2.11086 11.3747 2.43016 11.5078 2.76718 11.5078H4.4878V10.4789H4.49667Z', + fill: 'currentFill' + }) + ] + ) + ) +} diff --git a/frontend/src/pages/team/DeviceGroups/index.vue b/frontend/src/pages/team/DeviceGroups/index.vue new file mode 100644 index 0000000000..9d89e4d91f --- /dev/null +++ b/frontend/src/pages/team/DeviceGroups/index.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/frontend/src/pages/team/routes.js b/frontend/src/pages/team/routes.js index 3c605cd67d..5aec06ac11 100644 --- a/frontend/src/pages/team/routes.js +++ b/frontend/src/pages/team/routes.js @@ -5,6 +5,7 @@ import TeamApplications from './Applications/index.vue' import TeamAuditLog from './AuditLog.vue' import TeamBillOfMaterials from './BOM/index.vue' import TeamBilling from './Billing.vue' +import DeviceGroups from './DeviceGroups/index.vue' import TeamDevices from './Devices/index.vue' import TeamInstances from './Instances.vue' import Library from './Library/index.vue' @@ -215,6 +216,14 @@ export default [ meta: { title: 'Team - Bill of Materials' } + }, + { + name: 'device-groups', + path: 'device-groups', + component: DeviceGroups, + meta: { + title: 'Team - Remote Instances Groups' + } } ] }, diff --git a/frontend/src/store/account.js b/frontend/src/store/account.js index 6ab531ce18..843bb8ee10 100644 --- a/frontend/src/store/account.js +++ b/frontend/src/store/account.js @@ -192,7 +192,8 @@ const getters = { isBOMFeatureEnabled: preCheck.isBOMFeatureEnabledForPlatform && preCheck.isBOMFeatureEnabledForTeam, isTimelineFeatureEnabled: preCheck.isTimelineFeatureEnabledForPlatform && preCheck.isTimelineFeatureEnabledForTeam, isMqttBrokerFeatureEnabled: preCheck.isMqttBrokerFeatureEnabledForPlatform && preCheck.isMqttBrokerFeatureEnabledForTeam, - devOpsPipelinesFeatureEnabled: preCheck.devOpsPipelinesFeatureEnabledForPlatform + devOpsPipelinesFeatureEnabled: preCheck.devOpsPipelinesFeatureEnabledForPlatform, + isDeviceGroupsFeatureEnabled: !!state.team?.type?.properties?.features?.deviceGroups } } } diff --git a/frontend/src/store/ux.js b/frontend/src/store/ux.js index 7cce07cdd8..4956340fbd 100644 --- a/frontend/src/store/ux.js +++ b/frontend/src/store/ux.js @@ -5,6 +5,7 @@ import { TableIcon, TemplateIcon, UserGroupIcon, UsersIcon } from '@heroicons/vue/outline' +import DeviceGroupOutlineIcon from '../components/icons/DeviceGroupOutline.js' import PipelinesIcon from '../components/icons/Pipelines.js' import ProjectsIcon from '../components/icons/Projects.js' import usePermissions from '../composables/Permissions.js' @@ -83,6 +84,15 @@ const getters = { title: 'Operations', hidden: !hasAMinimumTeamRoleOf(Roles.Viewer), entries: [ + { + label: 'Groups', + to: { name: 'device-groups', params: { team_slug: team.slug } }, + tag: 'device-groups', + icon: DeviceGroupOutlineIcon, + disabled: noBilling, + featureUnavailable: !features.isDeviceGroupsFeatureEnabled, + hidden: hasALowerOrEqualTeamRoleThan(Roles.Member) + }, { label: 'Pipelines', to: { name: 'team-pipelines', params: { team_slug: team.slug } }, From 4fe4f89e9e7b813d6f0204ed9f76cd3b4aef2667 Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 15 Jan 2025 16:28:48 +0200 Subject: [PATCH 31/47] adding missing after in unit tests --- test/unit/forge/ee/routes/api/team-device-groups_spec.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/unit/forge/ee/routes/api/team-device-groups_spec.js b/test/unit/forge/ee/routes/api/team-device-groups_spec.js index cc670e807f..7b137bf7ab 100644 --- a/test/unit/forge/ee/routes/api/team-device-groups_spec.js +++ b/test/unit/forge/ee/routes/api/team-device-groups_spec.js @@ -82,6 +82,10 @@ describe('Team Device Groups API', function () { TestObjects.instance = await app.factory.createInstance({ name: 'B-team-instance' }, TestObjects.application, app.stack, app.template, app.projectType, { start: false }) }) + after(async function () { + await app.close() + }) + describe('the preHandler', () => { it('should return a 404 not found response when no team is provided', async () => { const sid = await login('bob', 'bbPassword') From e486a0e5103b75fd2b513ba489fde22fcc78df87 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Wed, 15 Jan 2025 14:57:12 +0000 Subject: [PATCH 32/47] Update Team Device Groups routes to use hashid not slug --- forge/ee/routes/teamDeviceGroups/index.js | 2 +- .../ee/routes/api/team-device-groups_spec.js | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/forge/ee/routes/teamDeviceGroups/index.js b/forge/ee/routes/teamDeviceGroups/index.js index b910875df5..d055e3c933 100644 --- a/forge/ee/routes/teamDeviceGroups/index.js +++ b/forge/ee/routes/teamDeviceGroups/index.js @@ -20,7 +20,7 @@ module.exports = async function (app) { } try { - request.team = await app.db.models.Team.bySlug(request.params.teamId) + 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' }) } diff --git a/test/unit/forge/ee/routes/api/team-device-groups_spec.js b/test/unit/forge/ee/routes/api/team-device-groups_spec.js index 7b137bf7ab..7c71e2a924 100644 --- a/test/unit/forge/ee/routes/api/team-device-groups_spec.js +++ b/test/unit/forge/ee/routes/api/team-device-groups_spec.js @@ -116,7 +116,7 @@ describe('Team Device Groups API', function () { const response = await app.inject({ method: 'GET', - url: `/api/v1/teams/${TestObjects.ATeam.slug}/device-groups`, + url: `/api/v1/teams/${TestObjects.ATeam.hashid}/device-groups`, cookies: { sid } }) @@ -127,7 +127,7 @@ describe('Team Device Groups API', function () { const sid = await login('bob', 'bbPassword') const response = await app.inject({ method: 'GET', - url: `/api/v1/teams/${TestObjects.BTeam.slug}/device-groups`, + url: `/api/v1/teams/${TestObjects.BTeam.hashid}/device-groups`, cookies: { sid } }) response.statusCode.should.equal(200) @@ -138,7 +138,7 @@ describe('Team Device Groups API', function () { const response = await app.inject({ method: 'GET', - url: `/api/v1/teams/${TestObjects.BTeam.slug}/device-groups`, + url: `/api/v1/teams/${TestObjects.BTeam.hashid}/device-groups`, cookies: { sid } }) @@ -162,7 +162,7 @@ describe('Team Device Groups API', function () { const response = await app.inject({ method: 'GET', - url: `/api/v1/teams/${TestObjects.BTeam.slug}/device-groups`, + url: `/api/v1/teams/${TestObjects.BTeam.hashid}/device-groups`, cookies: { sid } }) @@ -199,7 +199,7 @@ describe('Team Device Groups API', function () { const response = await app.inject({ method: 'GET', - url: `/api/v1/teams/${TestObjects.ATeam.slug}/device-groups`, + url: `/api/v1/teams/${TestObjects.ATeam.hashid}/device-groups`, cookies: { sid } }) @@ -236,7 +236,7 @@ describe('Team Device Groups API', function () { const response = await app.inject({ method: 'GET', - url: `/api/v1/teams/${TestObjects.CTeam.slug}/device-groups`, + url: `/api/v1/teams/${TestObjects.CTeam.hashid}/device-groups`, cookies: { sid } }) @@ -254,7 +254,7 @@ describe('Team Device Groups API', function () { const response = await app.inject({ method: 'GET', - url: `/api/v1/teams/${TestObjects.CTeam.slug}/device-groups`, + url: `/api/v1/teams/${TestObjects.CTeam.hashid}/device-groups`, cookies: { sid } }) @@ -266,7 +266,7 @@ describe('Team Device Groups API', function () { const response = await app.inject({ method: 'GET', - url: `/api/v1/teams/${TestObjects.CTeam.slug}/device-groups`, + url: `/api/v1/teams/${TestObjects.CTeam.hashid}/device-groups`, cookies: { sid } }) @@ -278,7 +278,7 @@ describe('Team Device Groups API', function () { const response = await app.inject({ method: 'GET', - url: `/api/v1/teams/${TestObjects.CTeam.slug}/device-groups`, + url: `/api/v1/teams/${TestObjects.CTeam.hashid}/device-groups`, cookies: { sid } }) @@ -290,7 +290,7 @@ describe('Team Device Groups API', function () { const response = await app.inject({ method: 'GET', - url: `/api/v1/teams/${TestObjects.CTeam.slug}/device-groups`, + url: `/api/v1/teams/${TestObjects.CTeam.hashid}/device-groups`, cookies: { sid } }) From 62effe3c0a0d41d04a7708871467d7a19771c512 Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 15 Jan 2025 17:20:04 +0200 Subject: [PATCH 33/47] add the ability to create a group and load groups + team group api --- frontend/src/api/team.js | 8 +- .../src/pages/team/DeviceGroups/index.vue | 77 +++++++++++++++---- 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/frontend/src/api/team.js b/frontend/src/api/team.js index 4932eac1fe..a574bb0771 100644 --- a/frontend/src/api/team.js +++ b/frontend/src/api/team.js @@ -432,6 +432,11 @@ const getDependencies = (teamId) => { .then(res => res.data) } +const getTeamDeviceGroups = (teamId) => { + return client.get(`/api/v1/teams/${teamId}/device-groups`) + .then(res => res.data) +} + /** * Calls api routes in team.js * See [routes/api/team.js](../../../forge/routes/api/team.js) @@ -464,5 +469,6 @@ export default { deleteTeamDeviceProvisioningToken, bulkDeviceDelete, bulkDeviceMove, - getDependencies + getDependencies, + getTeamDeviceGroups } diff --git a/frontend/src/pages/team/DeviceGroups/index.vue b/frontend/src/pages/team/DeviceGroups/index.vue index 9d89e4d91f..85253e8a4d 100644 --- a/frontend/src/pages/team/DeviceGroups/index.vue +++ b/frontend/src/pages/team/DeviceGroups/index.vue @@ -79,24 +79,24 @@

      Enter the name and description of the Device Group to create.

      -
      +
      -
      +
      - Name + Name Description
      @@ -112,8 +112,14 @@ import { SearchIcon } from '@heroicons/vue/outline' import { mapGetters } from 'vuex' +import ApplicationAPI from '../../../api/application.js' + +import teamApi from '../../../api/team.js' + import EmptyState from '../../../components/EmptyState.vue' import FormRow from '../../../components/FormRow.vue' +import usePermissions from '../../../composables/Permissions.js' +import Alerts from '../../../services/alerts.js' import FfButton from '../../../ui-components/components/Button.vue' import FfListbox from '../../../ui-components/components/form/ListBox.vue' @@ -126,6 +132,10 @@ export default { EmptyState, SearchIcon }, + setup () { + const { hasPermission } = usePermissions() + return { hasPermission } + }, data () { return { loading: false, @@ -136,23 +146,62 @@ export default { description: '', application: '' }, - applications: [ - { - label: 'qwe', - value: '123' - } - ] + applications: [] } }, computed: { - ...mapGetters('account', ['featuresCheck']), + ...mapGetters('account', ['featuresCheck', 'team']), filteredDeviceGroups () { return this.deviceGroups + }, + applicationOptions () { + return this.applications.map(app => ({ label: app.name, value: app.id })) + } + }, + mounted () { + if (this.hasPermission('team:device-group:list')) { + this.loadTeamDeviceGroups() } }, methods: { async showCreateDeviceGroupDialog () { - this.$refs['create-dialog'].show() + this.getApplications() + .then(() => this.$refs['create-dialog'].show()) + .catch(e => e) + }, + getApplications () { + return teamApi.getTeamApplications(this.team.id, { includeApplicationSummary: false }) + .then((res) => { + this.applications = res.applications + }) + .catch(e => e) + }, + async loadTeamDeviceGroups () { + return teamApi.getTeamDeviceGroups(this.team.id) + .then(res => { + this.deviceGroups = res.groups + }) + .catch(e => e) + }, + async createDeviceGroup () { + if (!this.input.name) { + Alerts.emit('Device Group name is required', 'warning') + return + } + if (!this.input.application) { + Alerts.emit('An application is required', 'warning') + return + } + + ApplicationAPI.createDeviceGroup(this.input.application, this.input.name, this.input.description) + .then((result) => { + this.$refs['create-dialog'].close() + this.loadTeamDeviceGroups() + }) + .catch((err) => { + console.error(err) + Alerts.emit('Failed to create Device Group. Check the console for more details', 'error', 7500) + }) } } } From 6c3c543d4967b46a7edb82b4e08625c011a18362 Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 15 Jan 2025 18:04:29 +0200 Subject: [PATCH 34/47] extract the pipelines section into a common component --- .../src/components/sections/section-block.vue | 98 ++++++++++++++ .../src/pages/team/DeviceGroups/index.vue | 13 +- .../Pipelines/components/TeamPipeline.vue | 124 ------------------ frontend/src/pages/team/Pipelines/index.vue | 60 ++++++++- 4 files changed, 163 insertions(+), 132 deletions(-) create mode 100644 frontend/src/components/sections/section-block.vue delete mode 100644 frontend/src/pages/team/Pipelines/components/TeamPipeline.vue diff --git a/frontend/src/components/sections/section-block.vue b/frontend/src/components/sections/section-block.vue new file mode 100644 index 0000000000..1a5aa23052 --- /dev/null +++ b/frontend/src/components/sections/section-block.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/frontend/src/pages/team/DeviceGroups/index.vue b/frontend/src/pages/team/DeviceGroups/index.vue index 85253e8a4d..0e0252856c 100644 --- a/frontend/src/pages/team/DeviceGroups/index.vue +++ b/frontend/src/pages/team/DeviceGroups/index.vue @@ -49,7 +49,16 @@
      • - device-group + + + +

      @@ -118,6 +127,7 @@ import teamApi from '../../../api/team.js' import EmptyState from '../../../components/EmptyState.vue' import FormRow from '../../../components/FormRow.vue' +import SectionBlock from '../../../components/sections/section-block.vue' import usePermissions from '../../../composables/Permissions.js' import Alerts from '../../../services/alerts.js' import FfButton from '../../../ui-components/components/Button.vue' @@ -126,6 +136,7 @@ import FfListbox from '../../../ui-components/components/form/ListBox.vue' export default { name: 'DeviceGroups', components: { + SectionBlock, FfListbox, FormRow, FfButton, diff --git a/frontend/src/pages/team/Pipelines/components/TeamPipeline.vue b/frontend/src/pages/team/Pipelines/components/TeamPipeline.vue deleted file mode 100644 index 8ddb274a17..0000000000 --- a/frontend/src/pages/team/Pipelines/components/TeamPipeline.vue +++ /dev/null @@ -1,124 +0,0 @@ - - - - - diff --git a/frontend/src/pages/team/Pipelines/index.vue b/frontend/src/pages/team/Pipelines/index.vue index 702f5c9721..3095687767 100644 --- a/frontend/src/pages/team/Pipelines/index.vue +++ b/frontend/src/pages/team/Pipelines/index.vue @@ -50,7 +50,21 @@

      • - + + + +

      @@ -76,20 +90,23 @@ - From 359f2786c7566161186123dff20379edd6176ab4 Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 15 Jan 2025 18:51:08 +0200 Subject: [PATCH 37/47] update device groups nomenclature --- .../src/pages/team/DeviceGroups/index.vue | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/src/pages/team/DeviceGroups/index.vue b/frontend/src/pages/team/DeviceGroups/index.vue index 6976f2c827..92e78aa9c2 100644 --- a/frontend/src/pages/team/DeviceGroups/index.vue +++ b/frontend/src/pages/team/DeviceGroups/index.vue @@ -1,16 +1,16 @@ @@ -59,10 +59,10 @@ - + - + -