Skip to content

Commit

Permalink
Merge pull request #4282 from FlowFuse/3818-rename-snapshot
Browse files Browse the repository at this point in the history
Edit snapshot
  • Loading branch information
knolleary authored Aug 1, 2024
2 parents d9dda8b + 6cc940c commit 881fbea
Show file tree
Hide file tree
Showing 22 changed files with 618 additions and 31 deletions.
4 changes: 4 additions & 0 deletions docs/user/envvar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 14 additions & 0 deletions docs/user/snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions forge/auditLog/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
},
Expand Down
3 changes: 3 additions & 0 deletions forge/auditLog/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
},
Expand Down
28 changes: 28 additions & 0 deletions forge/db/controllers/Snapshot.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions forge/lib/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },

Expand Down
50 changes: 50 additions & 0 deletions forge/routes/api/snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down
21 changes: 20 additions & 1 deletion frontend/src/api/snapshots.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions frontend/src/components/audit-log/AuditEntryIcon.vue
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ const iconMap = {
],
clock: [
'project.snapshot.created',
'project.snapshot.updated',
'project.device.snapshot.created',
'project.snapshot.deleted',
'project.snapshot.rollback',
Expand All @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/components/audit-log/AuditEntryVerbose.vue
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,11 @@
<span v-if="!error && entry.body?.device && entry.body.snapshot">Snapshot '{{ entry.body.snapshot?.name }}' has been been created from Application owned Device '{{ entry.body.device?.name }}'.</span>
<span v-else-if="!error">Device or Snapshot data not found in audit entry.</span>
</template>
<template v-else-if="entry.event === 'application.device.snapshot.updated'">
<label>{{ AuditEvents[entry.event] }}</label>
<span v-if="!error && entry.body && entry.body.updates">Snapshot '{{ entry.body.snapshot?.name }}' of Application owned Device '{{ entry.body.device?.name }}' has been been updated has with following changes: <AuditEntryUpdates :updates="entry.body.updates" /></span>
<span v-else-if="!error">Change data not found in audit entry.</span>
</template>
<template v-else-if="entry.event === 'application.device.snapshot.deleted'">
<label>{{ AuditEvents[entry.event] }}</label>
<span v-if="!error && entry.body?.device && entry.body.snapshot">Snapshot '{{ entry.body.snapshot?.name }}' has been been deleted for Application owned Device '{{ entry.body.device?.name }}'.</span>
Expand Down Expand Up @@ -541,6 +546,11 @@
<span v-if="!error && entry.body?.project && entry.body.snapshot">A new Snapshot '{{ entry.body.snapshot?.name }}' has been created for Instance '{{ entry.body.project?.name }}'.</span>
<span v-else-if="!error">Instance data not found in audit entry.</span>
</template>
<template v-else-if="entry.event === 'project.snapshot.updated'">
<label>{{ AuditEvents[entry.event] }}</label>
<span v-if="!error && entry.body && entry.body.updates">Snapshot '{{ entry.body.snapshot?.name }}' of Instance '{{ entry.body.project?.name }}' has been been updated has with following changes: <AuditEntryUpdates :updates="entry.body.updates" /></span>
<span v-else-if="!error">Change data not found in audit entry.</span>
</template>
<template v-else-if="entry.event === 'project.device.snapshot.created'">
<label>{{ AuditEvents[entry.event] }}</label>
<span v-if="!error && entry.body?.project && entry.body.snapshot">A new Snapshot '{{ entry.body.snapshot?.name }}' has been created from Device '{{ entry.body.device?.name }}' for Instance '{{ entry.body.project?.name }}'.</span>
Expand Down
112 changes: 112 additions & 0 deletions frontend/src/components/dialogs/SnapshotEditDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<template>
<ff-dialog ref="dialog" :header="'Edit Snapshot: ' + originalName" data-el="snapshot-edit-dialog" @confirm="confirm()">
<template #default>
<form class="space-y-6 mt-2" data-form="snapshot-edit" @submit.prevent>
<FormRow ref="name" v-model="input.name" :error="errors.name" data-form="snapshot-name">Name</FormRow>
<FormRow data-form="snapshot-description">
Description
<template #input>
<textarea v-model="input.description" rows="8" class="ff-input ff-text-input" style="height: auto" />
</template>
</FormRow>
</form>
<p class="text-gray-600 italic">
<span>
Note: Changes made to a snapshot will not be immediately reflected on devices that are already running this snapshot.
</span>
</p>
</template>
<template #actions>
<ff-button kind="secondary" data-action="dialog-cancel" :disabled="submitted" @click="cancel()">Cancel</ff-button>
<ff-button :kind="kind" data-action="dialog-confirm" :disabled="!formValid" @click="confirm()">Update</ff-button>
</template>
</ff-dialog>
</template>
<script>
import snapshotsApi from '../../api/snapshots.js'
import alerts from '../../services/alerts.js'
import FormRow from '../FormRow.vue'
export default {
name: 'SnapshotEditDialog',
components: {
FormRow
},
emits: ['snapshot-updated', 'close'],
setup () {
return {
show (snapshot) {
this.submitted = false
this.errors.name = ''
this.snapshot = snapshot
this.originalName = snapshot.name || 'unnamed'
this.input.name = snapshot.name || ''
this.input.description = snapshot.description || ''
this.$refs.dialog.show()
setTimeout(() => {
this.$refs.name.focus()
}, 20)
}
}
},
data () {
return {
submitted: false,
originalName: '',
input: {
name: ''
},
snapshot: null,
errors: {
name: ''
}
}
},
computed: {
formValid () {
return this.validate()
}
},
mounted () {
},
methods: {
validate () {
if (!this.input.name) {
this.errors.name = 'Name is required'
} else {
this.errors.name = ''
}
return !this.submitted && !!(this.input.name) && !this.errors.name
},
cancel () {
this.$refs.dialog.close()
},
confirm () {
if (this.validate()) {
this.submitted = true
const opts = {
name: this.input.name,
description: this.input.description || ''
}
snapshotsApi.updateSnapshot(this.snapshot.id, opts).then((data) => {
const updatedSnapshot = {
...this.snapshot
}
updatedSnapshot.name = this.input.name
updatedSnapshot.description = this.input.description
this.$emit('snapshot-updated', updatedSnapshot)
alerts.emit('Snapshot updated.', 'confirmation')
this.$refs.dialog.close()
}).catch(err => {
console.error(err)
alerts.emit('Failed to update snapshot.', 'warning')
}).finally(() => {
this.submitted = false
})
}
}
}
}
</script>
2 changes: 2 additions & 0 deletions frontend/src/data/audit-events.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"application.device.assigned": "Device Assigned to Application",
"application.device.unassigned": "Device Unassigned from Application",
"application.device.snapshot.created": "Device Snapshot Created",
"application.device.snapshot.updated": "Device Snapshot Updated",
"application.device.snapshot.deleted": "Device Snapshot Deleted",
"application.device.snapshot.exported": "Device Snapshot Exported",
"application.device.snapshot.imported": "Snapshot Imported",
Expand Down Expand Up @@ -74,6 +75,7 @@
"project.stack.changed": "Instance Stack Changed",
"project.settings.updated": "Instance Settings Updated",
"project.snapshot.created": "Instance Snapshot Created",
"project.snapshot.updated": "Instance Snapshot Updated",
"project.device.snapshot.created": "Device Snapshot Created",
"project.snapshot.rolled-back": "Instance Rolled Back",
"project.snapshot.rollback": "Instance Rolled Back",
Expand Down
Loading

0 comments on commit 881fbea

Please sign in to comment.