+ This will overwrite the current instance.
+ All changes to the flows and settings made since the last snapshot will be lost.
+
+
+
+ When ticked, environment variables from the snapshot are only added if the instance does not already have them.
+ When unticked, the instance's environment variables are replaced by the snapshot's.
+
+
Are you sure you want to deploy to this snapshot?
+
+
+
+
diff --git a/frontend/src/mixins/Snapshots.js b/frontend/src/mixins/Snapshots.js
index 1f09f4a943..daf174d9f6 100644
--- a/frontend/src/mixins/Snapshots.js
+++ b/frontend/src/mixins/Snapshots.js
@@ -1,6 +1,9 @@
+import { markRaw } from 'vue'
+
import InstanceApi from '../api/instances.js'
import SnapshotApi from '../api/projectSnapshots.js'
import SnapshotsApi from '../api/snapshots.js'
+import RollbackSnapshotDialog from '../components/dialogs/RollbackSnapshotDialog.vue'
import { downloadData } from '../composables/Download.js'
import Alerts from '../services/alerts.js'
import Dialog from '../services/dialog.js'
@@ -23,17 +26,23 @@ export default {
},
// snapshot actions - rollback
showRollbackDialog (snapshot, alterLoadingState = false) {
+ let mergeEnvVars = true
return new Promise((resolve) => {
Dialog.show({
header: 'Restore Snapshot',
kind: 'danger',
- text: `This will overwrite the current instance.
- All changes to the flows, settings and environment variables made since the last snapshot will be lost.
- Are you sure you want to deploy to this snapshot?`,
+ is: {
+ component: markRaw(RollbackSnapshotDialog),
+ on: {
+ 'keep-env-vars-changed' (value) {
+ mergeEnvVars = value
+ }
+ }
+ },
confirmLabel: 'Confirm'
}, async () => {
if (alterLoadingState) this.loading = true
- return SnapshotApi.rollbackSnapshot(this.instance.id, snapshot.id)
+ return SnapshotApi.rollbackSnapshot(this.instance.id, snapshot.id, { mergeEnvVars })
.then(() => {
Alerts.emit('Successfully deployed snapshot.', 'confirmation')
resolve()
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..438d62798c 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="keep-env-vars"]').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)
From df7286d5d3ce575ad3577f8b6cd79c507827a94d Mon Sep 17 00:00:00 2001
From: andypalmi
Date: Wed, 21 Oct 2026 12:03:36 +0200
Subject: [PATCH 2/4] Regenerate API types for the rollback mergeEnvVars option
---
frontend/src/types/generated.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/frontend/src/types/generated.ts b/frontend/src/types/generated.ts
index f4d492c86f..6800d5e28d 100644
--- a/frontend/src/types/generated.ts
+++ b/frontend/src/types/generated.ts
@@ -4894,6 +4894,8 @@ export interface paths {
content: {
"application/json": {
snapshot: string;
+ /** @default false */
+ mergeEnvVars?: boolean;
};
};
};
From 9e9cfc58d6541d16a59bc8a14d1e876d8572eff1 Mon Sep 17 00:00:00 2001
From: andypalmi
Date: Fri, 23 Oct 2026 12:13:24 +0200
Subject: [PATCH 3/4] Offer merge or replace for rollback env vars as a single
select
The checkbox was ambiguous about what happens to the snapshot's variables. A radio group now spells out what Merge and Replace each do, and the radio button gains a multiline description variant.
---
.../dialogs/RollbackSnapshotDialog.vue | 35 ++++++++++++-------
frontend/src/mixins/Snapshots.js | 2 +-
.../components/form/RadioButton.vue | 7 +++-
.../components/form/RadioGroup.vue | 6 ++++
.../ui-components/stylesheets/ff-core.scss | 4 +++
.../version-history/snapshots.spec.js | 2 +-
6 files changed, 41 insertions(+), 15 deletions(-)
diff --git a/frontend/src/components/dialogs/RollbackSnapshotDialog.vue b/frontend/src/components/dialogs/RollbackSnapshotDialog.vue
index f43424f713..e1a94d3efb 100644
--- a/frontend/src/components/dialogs/RollbackSnapshotDialog.vue
+++ b/frontend/src/components/dialogs/RollbackSnapshotDialog.vue
@@ -4,15 +4,14 @@
This will overwrite the current instance.
All changes to the flows and settings made since the last snapshot will be lost.
-
-
- When ticked, environment variables from the snapshot are only added if the instance does not already have them.
- When unticked, the instance's environment variables are replaced by the snapshot's.
-