diff --git a/docs/user/envvar.md b/docs/user/envvar.md index ce71c0ef22..d2f7e9e9a1 100644 --- a/docs/user/envvar.md +++ b/docs/user/envvar.md @@ -62,3 +62,7 @@ In addition, the following variables are set when running on a device: When deploying the same set of flows out to multiple devices, these variables can be used by the flows to identify the specific device being run on. + +NOTE: `FF_SNAPSHOT_NAME` will not be immediately updated when the current snapshot is edited. +It will only be updated when the snapshot is changed or a setting that causes the device to +be restarted is changed. diff --git a/docs/user/snapshots.md b/docs/user/snapshots.md index b00f91f25b..d0cdc103ad 100644 --- a/docs/user/snapshots.md +++ b/docs/user/snapshots.md @@ -24,6 +24,20 @@ To create a snapshot: The list of snapshots will update with the newly created entry at the top. +## Edit a snapshot + +To edit a snapshot: + +1. Go to the instance's page and select the **Snapshots** tab. +2. Open the dropdown menu to the right of the snapshot you want to edit and + select the **Edit Snapshot** option. +3. Update the name and description as required. +4. Click **Update** + +NOTE: +Changes made to a snapshot will not be immediately reflected on devices that are already running this snapshot. + + ## Download a snapshot A snapshot can be downloaded to your local machine for backup or sharing. diff --git a/forge/auditLog/application.js b/forge/auditLog/application.js index 7f4d7dbab8..4464512cdf 100644 --- a/forge/auditLog/application.js +++ b/forge/auditLog/application.js @@ -26,6 +26,9 @@ module.exports = { async created (actionedBy, error, application, device, snapshot) { await log('application.device.snapshot.created', actionedBy, application?.id, generateBody({ error, device, snapshot })) }, + async updated (actionedBy, error, application, device, snapshot, updates) { + await log('application.device.snapshot.updated', actionedBy, application?.id, generateBody({ error, device, snapshot, updates })) + }, async deleted (actionedBy, error, application, device, snapshot) { await log('application.device.snapshot.deleted', actionedBy, application?.id, generateBody({ error, device, snapshot })) }, diff --git a/forge/auditLog/project.js b/forge/auditLog/project.js index 89d0d386e5..4e07398e42 100644 --- a/forge/auditLog/project.js +++ b/forge/auditLog/project.js @@ -78,6 +78,9 @@ module.exports = { async created (actionedBy, error, project, snapshot) { await log('project.snapshot.created', actionedBy, project?.id, generateBody({ error, project, snapshot })) }, + 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 })) }, diff --git a/forge/db/controllers/Snapshot.js b/forge/db/controllers/Snapshot.js index 8061b1568d..3ed3dfa7c6 100644 --- a/forge/db/controllers/Snapshot.js +++ b/forge/db/controllers/Snapshot.js @@ -1,3 +1,6 @@ +const { ValidationError } = require('sequelize') +const hasProperty = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key) + module.exports = { /** * Get a snapshot by ID @@ -108,6 +111,31 @@ module.exports = { return true }, + /** + * Update a snapshot + * @param {*} app - app instance + * @param {*} snapshot - snapshot object + * @param {*} options - options to update + * @param {String} [options.name] - name of the snapshot + * @param {String} [options.description] - description of the snapshot + */ + async updateSnapshot (app, snapshot, options) { + const updates = {} + if (hasProperty(options, 'name') && (typeof options.name !== 'string' || options.name.trim() === '')) { + throw new ValidationError('Snapshot name is required') + } + if (options.name) { + updates.name = options.name + } + if (typeof options.description !== 'undefined') { + updates.description = options.description + } + if (Object.keys(updates).length > 0) { + await snapshot.update(updates) + } + return snapshot + }, + /** * Upload a snapshot. * @param {*} app - app instance diff --git a/forge/lib/permissions.js b/forge/lib/permissions.js index 1cabcd47c3..8cb046ff81 100644 --- a/forge/lib/permissions.js +++ b/forge/lib/permissions.js @@ -84,6 +84,7 @@ const Permissions = { 'snapshot:meta': { description: 'View a Snapshot', role: Roles.Viewer }, 'snapshot:full': { description: 'View full snapshot details excluding credentials', role: Roles.Member }, 'snapshot:export': { description: 'Export a snapshot including credentials', role: Roles.Member }, + 'snapshot:edit': { description: 'Edit a Snapshot', role: Roles.Owner }, 'snapshot:delete': { description: 'Delete a Snapshot', role: Roles.Owner }, 'snapshot:import': { description: 'Import a Snapshot', role: Roles.Owner }, diff --git a/forge/routes/api/snapshot.js b/forge/routes/api/snapshot.js index 037b5d48ba..4ab2de5314 100644 --- a/forge/routes/api/snapshot.js +++ b/forge/routes/api/snapshot.js @@ -7,6 +7,8 @@ * @memberof forge.routes.api */ +const { UpdatesCollection } = require('../../auditLog/formatters.js') + module.exports = async function (app) { /** @type {typeof import('../../db/controllers/Snapshot.js')} */ const snapshotController = app.db.controllers.Snapshot @@ -160,6 +162,54 @@ module.exports = async function (app) { reply.send({ status: 'okay' }) }) + /** + * Update a snapshot + */ + app.put('/:id', { + preHandler: app.needsPermission('snapshot:edit'), + schema: { + summary: 'Update a snapshot', + tags: ['Snapshots'], + params: { + type: 'object', + properties: { + id: { type: 'string' } + } + }, + body: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' } + } + }, + response: { + 200: { + $ref: 'Snapshot' + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + // capture the original name/description for the audit log + const snapshotBefore = { name: request.snapshot.name, description: request.snapshot.description } + // perform the update + const snapshot = await snapshotController.updateSnapshot(request.snapshot, request.body) + // log the update + const snapshotAfter = { name: snapshot.name, description: snapshot.description } + const updates = new UpdatesCollection() + updates.pushDifferences(snapshotBefore, snapshotAfter) + if (request.ownerType === 'device') { + const application = await request.owner.getApplication() + await applicationLogger.application.device.snapshot.updated(request.session.User, null, application, request.owner, request.snapshot, updates) + } else if (request.ownerType === 'instance') { + await projectLogger.project.snapshot.updated(request.session.User, null, request.owner, request.snapshot, updates) + } + reply.send(projectSnapshotView.snapshot(snapshot)) + }) + /** * Export a snapshot for later import in another project or platform */ diff --git a/frontend/src/api/snapshots.js b/frontend/src/api/snapshots.js index 14fa0a18ad..8cd5986b5d 100644 --- a/frontend/src/api/snapshots.js +++ b/frontend/src/api/snapshots.js @@ -78,10 +78,29 @@ const deleteSnapshot = async (snapshotId) => { }) } +/** + * Update a snapshot + * @param {String} snapshotId - id of the snapshot + * @param {Object} options - options to update + * @param {String} [options.name] - name of the snapshot + * @param {String} [options.description] - description of the snapshot + */ +const updateSnapshot = async (snapshotId, options) => { + return client.put(`/api/v1/snapshots/${snapshotId}`, options).then(res => { + const props = { + 'snapshot-id': snapshotId, + 'updated-at': (new Date()).toISOString() + } + product.capture('$ff-snapshot-updated', props, {}) + return res.data + }) +} + export default { getSummary, getFullSnapshot, exportSnapshot, importSnapshot, - deleteSnapshot + deleteSnapshot, + updateSnapshot } diff --git a/frontend/src/components/audit-log/AuditEntryIcon.vue b/frontend/src/components/audit-log/AuditEntryIcon.vue index 91d881d89e..465430c6e2 100644 --- a/frontend/src/components/audit-log/AuditEntryIcon.vue +++ b/frontend/src/components/audit-log/AuditEntryIcon.vue @@ -167,6 +167,7 @@ const iconMap = { ], clock: [ 'project.snapshot.created', + 'project.snapshot.updated', 'project.device.snapshot.created', 'project.snapshot.deleted', 'project.snapshot.rollback', @@ -176,6 +177,7 @@ const iconMap = { 'project.snapshot.device-target-set', 'project.snapshot.deviceTarget', // legacy event 'application.device.snapshot.created', + 'application.device.snapshot.updated', 'application.device.snapshot.deleted', 'application.device.snapshot.exported', 'application.device.snapshot.imported', diff --git a/frontend/src/components/audit-log/AuditEntryVerbose.vue b/frontend/src/components/audit-log/AuditEntryVerbose.vue index 070e8ce323..7c61fcf624 100644 --- a/frontend/src/components/audit-log/AuditEntryVerbose.vue +++ b/frontend/src/components/audit-log/AuditEntryVerbose.vue @@ -392,6 +392,11 @@ Snapshot '{{ entry.body.snapshot?.name }}' has been been created from Application owned Device '{{ entry.body.device?.name }}'. Device or Snapshot data not found in audit entry. + +