diff --git a/forge/auditLog/project.js b/forge/auditLog/project.js index 21752632a7..cba775239c 100644 --- a/forge/auditLog/project.js +++ b/forge/auditLog/project.js @@ -84,8 +84,8 @@ module.exports = { async updated (actionedBy, error, project, snapshot, updates) { await log('project.snapshot.updated', actionedBy, project?.id, generateBody({ error, project, snapshot, updates })) }, - async rolledBack (actionedBy, error, project, snapshot) { - await log('project.snapshot.rolled-back', actionedBy, project?.id, generateBody({ error, project, snapshot })) + async rolledBack (actionedBy, error, project, snapshot, info) { + await log('project.snapshot.rolled-back', actionedBy, project?.id, generateBody({ error, project, snapshot, info })) }, async deleted (actionedBy, error, project, snapshot) { await log('project.snapshot.deleted', actionedBy, project?.id, generateBody({ error, project, snapshot })) diff --git a/forge/routes/api/projectActions.js b/forge/routes/api/projectActions.js index 5357bd8a97..16d8131aed 100644 --- a/forge/routes/api/projectActions.js +++ b/forge/routes/api/projectActions.js @@ -225,7 +225,8 @@ module.exports = async function (app) { body: { type: 'object', properties: { - snapshot: { type: 'string' } + snapshot: { type: 'string' }, + mergeEnvVars: { type: 'boolean', default: false } }, required: ['snapshot'] }, @@ -258,8 +259,8 @@ module.exports = async function (app) { restartProject = true } await app.db.controllers.Project.setInflightState(request.project, 'rollback') - await app.db.controllers.Project.importProjectSnapshot(request.project, snapshot) - await app.auditLog.Project.project.snapshot.rolledBack(request.session.User, null, request.project, snapshot) + await app.db.controllers.Project.importProjectSnapshot(request.project, snapshot, { mergeEnvVars: request.body.mergeEnvVars }) + await app.auditLog.Project.project.snapshot.rolledBack(request.session.User, null, request.project, snapshot, { mergeEnvVars: request.body.mergeEnvVars }) if (restartProject) { await app.db.controllers.Project.setInflightState(request.project, 'restarting') await app.containers.restartFlows(request.project) diff --git a/frontend/src/api/instances.js b/frontend/src/api/instances.js index ca106fac68..cf9548cd3b 100644 --- a/frontend/src/api/instances.js +++ b/frontend/src/api/instances.js @@ -134,9 +134,10 @@ const getInstanceDeviceSettings = async (instanceId) => { const updateInstanceDeviceSettings = async (instanceId, settings) => { return client.post(`/api/v1/projects/${instanceId}/devices/settings`, settings).then(res => res.data) } -const rollbackInstance = async (instanceId, snapshotId) => { +const rollbackInstance = async (instanceId, snapshotId, { mergeEnvVars } = {}) => { const data = { - snapshot: snapshotId + snapshot: snapshotId, + mergeEnvVars } return client.post(`/api/v1/projects/${instanceId}/actions/rollback`, data).then(res => res.data) } diff --git a/frontend/src/api/projectSnapshots.js b/frontend/src/api/projectSnapshots.js index a2748dd66d..89ac1a10cb 100644 --- a/frontend/src/api/projectSnapshots.js +++ b/frontend/src/api/projectSnapshots.js @@ -25,13 +25,13 @@ const create = async (projectId, options) => { }) } -const rollbackSnapshot = async (instanceId, snapshotId) => { +const rollbackSnapshot = async (instanceId, snapshotId, options) => { product.capture('ff-snapshot-rollback', { 'snapshot-id': snapshotId }, { instance: instanceId }) - return instanceApi.rollbackInstance(instanceId, snapshotId) + return instanceApi.rollbackInstance(instanceId, snapshotId, options) } /** diff --git a/frontend/src/components/audit-log/AuditEntryVerbose.vue b/frontend/src/components/audit-log/AuditEntryVerbose.vue index ad9d1f1261..e39dc464bc 100644 --- a/frontend/src/components/audit-log/AuditEntryVerbose.vue +++ b/frontend/src/components/audit-log/AuditEntryVerbose.vue @@ -691,7 +691,7 @@ @@ -35,6 +35,11 @@ export default { default: false, type: Boolean }, + // Render line breaks in the description as new lines + multilineDescription: { + default: false, + type: Boolean + }, // Full-width tappable tile layout, with the label stacked above its description tile: { default: false, diff --git a/frontend/src/ui-components/components/form/RadioGroup.vue b/frontend/src/ui-components/components/form/RadioGroup.vue index 43e97951e6..3e9a1dd059 100644 --- a/frontend/src/ui-components/components/form/RadioGroup.vue +++ b/frontend/src/ui-components/components/form/RadioGroup.vue @@ -8,6 +8,7 @@ :description="option.description" :disabled="option.disabled" :hide-description="orientation === 'horizontal'" + :multiline-description="multilineDescription" :tile="tile" @select="select" /> @@ -39,6 +40,11 @@ export default { default: null, type: Array }, + // Render line breaks in the option descriptions as new lines + multilineDescription: { + default: false, + type: Boolean + }, // Full-width tappable tile layout, with each label stacked above its description tile: { default: false, diff --git a/frontend/src/ui-components/stylesheets/ff-core.scss b/frontend/src/ui-components/stylesheets/ff-core.scss index 78df34f1c0..bca9f3ef3f 100644 --- a/frontend/src/ui-components/stylesheets/ff-core.scss +++ b/frontend/src/ui-components/stylesheets/ff-core.scss @@ -71,6 +71,10 @@ p code { .ff-description { font-size: $ff-funit-sm; color: var(--ff-color-text-subtle); + + &.ff-description--multiline { + white-space: pre-line; + } } hr { diff --git a/test/e2e/frontend/cypress/tests/instances/version-history/snapshots.spec.js b/test/e2e/frontend/cypress/tests/instances/version-history/snapshots.spec.js index d1498f050f..341ba72030 100644 --- a/test/e2e/frontend/cypress/tests/instances/version-history/snapshots.spec.js +++ b/test/e2e/frontend/cypress/tests/instances/version-history/snapshots.spec.js @@ -693,6 +693,7 @@ describe('FlowFuse - Instance Snapshots', () => { cy.get('[data-el="platform-dialog"]').should('be.visible') cy.get('[data-el="platform-dialog"] .ff-dialog-header').contains('Restore Snapshot') + cy.get('[data-el="platform-dialog"] [data-form="env-vars-mode"]').should('exist') // find .ff-btn--danger with text "Confirm" and click it cy.get('[data-el="platform-dialog"] .ff-btn--danger').contains('Confirm').click() @@ -702,6 +703,7 @@ describe('FlowFuse - Instance Snapshots', () => { const body = interception.request.body expect(body).to.have.property('snapshot') expect(body.snapshot).to.be.a('string') + expect(body.mergeEnvVars).to.equal(true) }) }) }) diff --git a/test/unit/forge/auditLog/project_spec.js b/test/unit/forge/auditLog/project_spec.js index 7ae472e949..c42d63f5a1 100644 --- a/test/unit/forge/auditLog/project_spec.js +++ b/test/unit/forge/auditLog/project_spec.js @@ -346,6 +346,14 @@ describe('Audit Log > Project', async function () { logEntry.body.snapshot.id.should.equal(SNAPSHOT.hashid) }) + it('Records whether env vars were merged when rolling back a snapshot', async function () { + await projectLogger.project.snapshot.rolledBack(ACTIONED_BY, null, PROJECT, SNAPSHOT, { mergeEnvVars: true }) + const logEntry = await getLog() + logEntry.should.have.property('event', 'project.snapshot.rolled-back') + logEntry.body.should.only.have.keys('project', 'snapshot', 'info') + logEntry.body.info.should.eql({ mergeEnvVars: true }) + }) + it('Provides a logger for deleteing a snapshot of a project', async function () { await projectLogger.project.snapshot.deleted(ACTIONED_BY, null, PROJECT, SNAPSHOT) // check log stored diff --git a/test/unit/forge/routes/api/projectSnapshots_spec.js b/test/unit/forge/routes/api/projectSnapshots_spec.js index c042a01b43..9344f4d6ba 100644 --- a/test/unit/forge/routes/api/projectSnapshots_spec.js +++ b/test/unit/forge/routes/api/projectSnapshots_spec.js @@ -937,6 +937,70 @@ describe('Project Snapshots API', function () { rolledBackSettings.env.should.have.property('two', 'b') }) + it('keeps the instance env vars when rolling back with mergeEnvVars', async function () { + const setEnv = async (env) => { + const result = await addFlowsToProject(app, + TestObjects.project1.id, + TestObjects.tokens.project, + TestObjects.tokens.alice, + [{ id: 'node1' }], + { testCreds: 'abc' }, + 'key1', + { env } + ) + result.updateProjectSettingsResponse.statusCode.should.equal(200) + } + const getEnv = async () => { + const response = await app.inject({ + method: 'GET', + url: `/api/v1/projects/${app.project.id}/settings`, + headers: { authorization: `Bearer ${TestObjects.tokens.project}` } + }) + return response.json().env + } + const rollback = (snapshotId, mergeEnvVars) => app.inject({ + method: 'POST', + url: `/api/v1/projects/${app.project.id}/actions/rollback`, + payload: { snapshot: snapshotId, mergeEnvVars }, + cookies: { sid: TestObjects.tokens.alice } + }) + + await setEnv([ + { name: 'one', value: 'a' }, + { name: 'two', value: 'b' } + ]) + const snapshot = (await createSnapshot(TestObjects.project1.id, 'merge-env-snapshot', TestObjects.tokens.alice)).json() + + // 'one' changed and 'three' added after the snapshot, 'two' removed + await setEnv([ + { name: 'one', value: 'a2' }, + { name: 'three', value: 'c' } + ]) + + const mergeResponse = await rollback(snapshot.id, true) + mergeResponse.statusCode.should.equal(200) + const mergeLog = await app.db.models.AuditLog.findOne({ where: { event: 'project.snapshot.rolled-back', entityId: app.project.id }, order: [['id', 'DESC']] }) + JSON.parse(mergeLog.body).info.should.eql({ mergeEnvVars: true }) + const merged = await getEnv() + merged.should.have.property('one', 'a2') // existing value kept + merged.should.have.property('two', 'b') // added from snapshot + merged.should.have.property('three', 'c') // existing key kept + + await setEnv([ + { name: 'one', value: 'a2' }, + { name: 'three', value: 'c' } + ]) + + const replaceResponse = await rollback(snapshot.id, false) + replaceResponse.statusCode.should.equal(200) + const replaceLog = await app.db.models.AuditLog.findOne({ where: { event: 'project.snapshot.rolled-back', entityId: app.project.id }, order: [['id', 'DESC']] }) + JSON.parse(replaceLog.body).info.should.eql({ mergeEnvVars: false }) + const replaced = await getEnv() + replaced.should.have.property('one', 'a') + replaced.should.have.property('two', 'b') + replaced.should.not.have.property('three') + }) + it('holds a restarting mask through the bounce when rolling back a running instance', async function () { const snapResponse = await createSnapshot(app.project.id, 'rollback-mask-snapshot', TestObjects.tokens.alice) snapResponse.statusCode.should.equal(200)