Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Edit snapshot #4282

Merged
merged 16 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading